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,438 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'crypto'
4
+ require_relative 'serialization'
5
+ require_relative 'binary_reader'
6
+ require_relative 'tl_object'
7
+
8
+ module Telegram
9
+ module Auth
10
+ include Serialization
11
+ include Crypto
12
+
13
+ # Complete DH handshake EXACTLY like Telethon authenticator.py
14
+ def self.perform_dh_handshake(plain_sender)
15
+ Rails.logger.info '🔐 Starting REAL DH handshake EXACTLY like Telethon authenticator.do_authentication()'
16
+
17
+ # Step 1: Generate nonce and send req_pq_multi
18
+ nonce_bytes = SecureRandom.random_bytes(16)
19
+ nonce = Serialization.bytes_to_int(nonce_bytes, 'big', signed: true)
20
+
21
+ Rails.logger.info '📤 Generating and sending nonce:'
22
+ Rails.logger.info " random bytes: #{nonce_bytes.unpack1('H*')}"
23
+ Rails.logger.info " parsed as big endian signed: #{nonce}"
24
+
25
+ res_pq_data = send_req_pq_multi(plain_sender, nonce)
26
+ return nil unless res_pq_data
27
+
28
+ # Step 2: Factor PQ and send req_DH_params
29
+ pq = res_pq_data[:pq]
30
+ p_int, q_int = Crypto.factorize_pq(pq)
31
+
32
+ # Convert to byte arrays EXACTLY like Telethon rsa.get_byte_array()
33
+ p_bytes = Serialization.integer_to_byte_array(p_int, signed: false)
34
+ q_bytes = Serialization.integer_to_byte_array(q_int, signed: false)
35
+ pq_bytes = Serialization.integer_to_byte_array(pq, signed: false)
36
+
37
+ # Generate new_nonce for DH exchange
38
+ new_nonce_bytes = SecureRandom.random_bytes(32)
39
+ new_nonce = Serialization.bytes_to_int(new_nonce_bytes, 'little', signed: true)
40
+
41
+ # Build and encrypt PQInnerData using byte arrays
42
+ # Add longer delay to avoid rate limiting (like Telethon retry logic)
43
+ Rails.logger.debug "⏱️ Adding 2s delay before req_DH_params to avoid rate limiting"
44
+ sleep(2)
45
+
46
+ pq_inner_data = build_pq_inner_data_bytes(res_pq_data, pq_bytes, p_bytes, q_bytes, new_nonce)
47
+ encryption_result = Crypto.rsa_encrypt_pq_inner_data(pq_inner_data, res_pq_data[:server_public_key_fingerprints])
48
+
49
+ dh_params = send_req_dh_params(plain_sender, res_pq_data, p_bytes, q_bytes,
50
+ encryption_result[:encrypted_data], encryption_result[:fingerprint])
51
+ return nil unless dh_params && dh_params[:success]
52
+
53
+ # Step 3: Complete DH exchange - add delay to avoid rate limiting
54
+ Rails.logger.debug "⏱️ Adding 1s delay before SetClientDHParams to avoid rate limiting"
55
+ sleep(1)
56
+
57
+ auth_key = complete_dh_exchange(plain_sender, res_pq_data, new_nonce, dh_params)
58
+
59
+ Rails.logger.info '🔑 DH handshake completed successfully'
60
+ auth_key
61
+ end
62
+
63
+ # Send req_pq_multi request EXACTLY like Telethon
64
+ def self.send_req_pq_multi(plain_sender, nonce)
65
+ # req_pq_multi#be7e8ef1 nonce:int128
66
+ request_data = TLObject.req_pq_multi(nonce: nonce)
67
+
68
+ Rails.logger.info "📤 Sending req_pq_multi with nonce=#{nonce} (0x#{nonce.to_s(16)})"
69
+
70
+ response = plain_sender.send(request_data)
71
+ return nil unless response && response.length >= 20
72
+
73
+ parse_res_pq_response(response, nonce)
74
+ end
75
+
76
+ # Parse ResPQ response EXACTLY like Telethon
77
+ def self.parse_res_pq_response(data, expected_nonce)
78
+ offset = 0
79
+
80
+ # Constructor
81
+ constructor = data[offset, 4].unpack1('L<')
82
+ offset += 4
83
+
84
+ unless constructor == 0x05162463 # resPQ
85
+ Rails.logger.error "❌ Invalid response constructor: 0x#{constructor.to_s(16)}"
86
+ return nil
87
+ end
88
+
89
+ # Parse nonce (16 bytes) - TL int128 is little-endian signed
90
+ nonce_bytes = data[offset, 16]
91
+ nonce = Serialization.bytes_to_int(nonce_bytes, 'little', signed: true)
92
+ offset += 16
93
+
94
+ if nonce != expected_nonce
95
+ Rails.logger.error "❌ Nonce mismatch! Expected: #{expected_nonce}, got: #{nonce}"
96
+ return nil
97
+ end
98
+
99
+ # Parse server_nonce (16 bytes) - TL int128 is little-endian signed
100
+ server_nonce_bytes = data[offset, 16]
101
+ server_nonce = Serialization.bytes_to_int(server_nonce_bytes, 'little', signed: true)
102
+ offset += 16
103
+
104
+ # Parse pq (TL string)
105
+ pq_length = data[offset, 1].unpack1('C')
106
+ offset += 1
107
+ pq_bytes = data[offset, pq_length]
108
+ pq = Serialization.bytes_to_int(pq_bytes, 'big', signed: false)
109
+ offset += pq_length
110
+
111
+ # Skip padding
112
+ padding = (4 - ((pq_length + 1) % 4)) % 4
113
+ offset += padding
114
+
115
+ # Parse fingerprints vector
116
+ vector_constructor = data[offset, 4].unpack1('L<')
117
+ offset += 4
118
+
119
+ unless vector_constructor == 0x1cb5c415 # Vector constructor
120
+ Rails.logger.error "❌ Invalid vector constructor: 0x#{vector_constructor.to_s(16)}"
121
+ return nil
122
+ end
123
+
124
+ fingerprint_count = data[offset, 4].unpack1('L<')
125
+ offset += 4
126
+
127
+ fingerprints = []
128
+ fingerprint_count.times do
129
+ fingerprint = data[offset, 8].unpack1('q<')
130
+ fingerprints << fingerprint
131
+ offset += 8
132
+ end
133
+
134
+ Rails.logger.info "✅ Got ResPQ: pq=#{pq}, fingerprints=#{fingerprints.map { |f| "0x#{f.to_s(16)}" }}"
135
+
136
+ {
137
+ nonce: nonce,
138
+ server_nonce: server_nonce,
139
+ pq: pq,
140
+ server_public_key_fingerprints: fingerprints
141
+ }
142
+ end
143
+
144
+ # Build PQInnerData using byte arrays EXACTLY like Telethon
145
+ def self.build_pq_inner_data_bytes(res_pq_data, pq_bytes, p_bytes, q_bytes, new_nonce)
146
+ # p_q_inner_data#83c95aec pq:string p:string q:string nonce:int128 server_nonce:int128 new_nonce:int256
147
+ inner_data = ''
148
+
149
+ # Constructor
150
+ inner_data += [0x83c95aec].pack('L<')
151
+
152
+ # pq, p, q as TL strings (already byte arrays)
153
+ inner_data += Serialization.pack_tl_string(pq_bytes)
154
+ inner_data += Serialization.pack_tl_string(p_bytes)
155
+ inner_data += Serialization.pack_tl_string(q_bytes)
156
+
157
+ # nonce:int128
158
+ inner_data += Serialization.serialize_int128(res_pq_data[:nonce])
159
+
160
+ # server_nonce:int128
161
+ inner_data += Serialization.serialize_int128(res_pq_data[:server_nonce])
162
+
163
+ # new_nonce:int256
164
+ inner_data += Serialization.serialize_int256(new_nonce)
165
+
166
+ Rails.logger.info '🔍 PQInnerData structure:'
167
+ Rails.logger.info ' constructor: 83c95aec'
168
+ Rails.logger.info " pq_bytes: #{pq_bytes.unpack1('H*')} (#{pq_bytes.length} bytes)"
169
+ Rails.logger.info " p_bytes: #{p_bytes.unpack1('H*')} (#{p_bytes.length} bytes)"
170
+ Rails.logger.info " q_bytes: #{q_bytes.unpack1('H*')} (#{q_bytes.length} bytes)"
171
+ Rails.logger.info " nonce: #{res_pq_data[:nonce]}"
172
+ Rails.logger.info " server_nonce: #{res_pq_data[:server_nonce]}"
173
+ Rails.logger.info " new_nonce: #{new_nonce}"
174
+ Rails.logger.info " total inner_data: #{inner_data.length} bytes"
175
+ Rails.logger.info " inner_data HEX: #{inner_data.unpack1('H*')}"
176
+
177
+ inner_data
178
+ end
179
+
180
+ # Build PQInnerData using TL Schema (like Telethon)
181
+ def self.build_pq_inner_data(res_pq_data, p, q, new_nonce)
182
+ # Convert integers to byte arrays like Telethon
183
+ pq_bytes = Serialization.integer_to_byte_array(res_pq_data[:pq], signed: false)
184
+ p_bytes = Serialization.integer_to_byte_array(p, signed: false)
185
+ q_bytes = Serialization.integer_to_byte_array(q, signed: false)
186
+
187
+ result = TLObject.p_q_inner_data(
188
+ pq: pq_bytes, p: p_bytes, q: q_bytes,
189
+ nonce: res_pq_data[:nonce],
190
+ server_nonce: res_pq_data[:server_nonce],
191
+ new_nonce: new_nonce
192
+ )
193
+
194
+ Rails.logger.debug "🔍 TL Schema PQInnerData HEX: #{result.unpack1('H*')}"
195
+ result
196
+ end
197
+
198
+ # Send req_DH_params EXACTLY like Telethon
199
+ def self.send_req_dh_params(plain_sender, res_pq_data, p, q, encrypted_data, used_fingerprint)
200
+
201
+ # req_DH_params#d712e4be nonce:int128 server_nonce:int128 p:string q:string public_key_fingerprint:long encrypted_data:string
202
+ Rails.logger.debug "🔍 req_DH_params encrypted_data HEX: #{encrypted_data.unpack1('H*')}"
203
+ # Use TL Schema for proper serialization
204
+ request_data = TLObject.req_dh_params(
205
+ nonce: res_pq_data[:nonce],
206
+ server_nonce: res_pq_data[:server_nonce],
207
+ p: p, q: q,
208
+ public_key_fingerprint: used_fingerprint,
209
+ encrypted_data: encrypted_data
210
+ )
211
+
212
+ Rails.logger.info "📤 Sending req_DH_params: #{request_data.length} bytes"
213
+ Rails.logger.info '🔍 Debug nonce serialization:'
214
+ Rails.logger.info " nonce value: #{res_pq_data[:nonce]}"
215
+ Rails.logger.info " nonce hex: 0x#{res_pq_data[:nonce].to_s(16)}"
216
+ nonce_serialized = Serialization.serialize_int128(res_pq_data[:nonce])
217
+ Rails.logger.info " nonce serialized: #{nonce_serialized.unpack1('H*')}"
218
+
219
+ # Test round-trip conversion
220
+ nonce_back = Serialization.bytes_to_int(nonce_serialized, 'little', signed: true)
221
+ Rails.logger.info " nonce round-trip: #{nonce_back} (matches: #{nonce_back == res_pq_data[:nonce]})"
222
+ Rails.logger.info "🔍 req_DH_params HEX: #{request_data.unpack1('H*')}"
223
+ Rails.logger.info '🔍 req_DH_params structure breakdown:'
224
+ offset = 0
225
+ Rails.logger.info " constructor: #{request_data[offset, 4].unpack1('H*')} (#{request_data[offset, 4].unpack1('L<').to_s(16)})"
226
+ offset += 4
227
+ Rails.logger.info " nonce: #{request_data[offset, 16].unpack1('H*')}"
228
+ offset += 16
229
+ Rails.logger.info " server_nonce: #{request_data[offset, 16].unpack1('H*')}"
230
+ offset += 16
231
+ p_len = request_data[offset, 1].unpack1('C')
232
+ Rails.logger.info " p_len: #{p_len}"
233
+ offset += 1
234
+ Rails.logger.info " p_data: #{request_data[offset, p_len].unpack1('H*')}"
235
+ offset += p_len + ((4 - ((p_len + 1) % 4)) % 4) # padding
236
+ q_len = request_data[offset, 1].unpack1('C')
237
+ Rails.logger.info " q_len: #{q_len}"
238
+ offset += 1
239
+ Rails.logger.info " q_data: #{request_data[offset, q_len].unpack1('H*')}"
240
+ offset += q_len + ((4 - ((q_len + 1) % 4)) % 4) # padding
241
+ Rails.logger.info " fingerprint: #{request_data[offset, 8].unpack1('q<').to_s(16)}"
242
+ offset += 8
243
+ enc_len_marker = request_data[offset, 1].unpack1('C')
244
+ if enc_len_marker == 254
245
+ # Long form: 254 + 3-byte length (need to pad with 0 for unpack)
246
+ len_bytes = request_data[offset + 1, 3] + "\x00" # pad to 4 bytes
247
+ enc_len = len_bytes.unpack1('L<')
248
+ Rails.logger.info " encrypted_len: #{enc_len} (long form: 254 + 3-byte length)"
249
+ else
250
+ enc_len = enc_len_marker
251
+ Rails.logger.info " encrypted_len: #{enc_len} (short form)"
252
+ end
253
+ Rails.logger.info " total_size: #{request_data.length} bytes"
254
+
255
+ begin
256
+ response = plain_sender.send(request_data)
257
+ Rails.logger.info "📥 Got req_DH_params response: #{response&.length || 0} bytes"
258
+
259
+ return nil unless response && response.length >= 20
260
+
261
+ parse_server_dh_params_response(response, res_pq_data)
262
+ rescue StandardError => e
263
+ Rails.logger.error "❌ req_DH_params failed: #{e.message}"
264
+ Rails.logger.error " Backtrace: #{e.backtrace.first(5).join('\n ')}"
265
+ return nil
266
+ end
267
+ end
268
+
269
+ # Parse server_DH_params response
270
+ def self.parse_server_dh_params_response(response, res_pq_data)
271
+ constructor = response[0, 4].unpack1('L<')
272
+
273
+ case constructor
274
+ when 0xd0e8075c # server_DH_params_ok
275
+ Rails.logger.info '✅ Got server_DH_params_ok'
276
+ # server_DH_params_ok#d0e8075c nonce:int128 server_nonce:int128 encrypted_answer:string
277
+ offset = 4
278
+
279
+ # Skip nonce and server_nonce (32 bytes total)
280
+ offset += 32
281
+
282
+ # Read encrypted_answer
283
+ answer_length = response[offset, 4].unpack1('L<')
284
+ offset += 4
285
+ encrypted_answer = response[offset, answer_length]
286
+
287
+ {
288
+ success: true,
289
+ encrypted_answer: encrypted_answer,
290
+ nonce: res_pq_data[:nonce],
291
+ server_nonce: res_pq_data[:server_nonce]
292
+ }
293
+ when 0x79cb045d # server_DH_params_fail
294
+ Rails.logger.error '❌ Got server_DH_params_fail'
295
+ nil
296
+ else
297
+ Rails.logger.error "❌ Unknown response constructor: 0x#{constructor.to_s(16)}"
298
+ nil
299
+ end
300
+ end
301
+
302
+ # Complete DH exchange EXACTLY like Telethon
303
+ def self.complete_dh_exchange(plain_sender, res_pq_data, new_nonce, dh_params)
304
+ Rails.logger.info '🔐 Completing DH exchange EXACTLY like Telethon...'
305
+
306
+ # Generate key and IV
307
+ server_nonce_bytes = Serialization.serialize_int128(res_pq_data[:server_nonce])
308
+ new_nonce_bytes_le = Serialization.serialize_int256(new_nonce)
309
+
310
+ key, iv = Crypto.generate_key_data_from_nonce(server_nonce_bytes, new_nonce_bytes_le)
311
+
312
+ Rails.logger.info "🔑 Generated AES key: #{key.length} bytes, IV: #{iv.length} bytes"
313
+
314
+ # Decrypt server_DH_inner_data
315
+ encrypted_answer = dh_params[:encrypted_answer]
316
+ raise Crypto::SecurityError.new('Step 3 AES block size mismatch') if encrypted_answer.length % 16 != 0
317
+
318
+ plain_text_answer = Crypto.decrypt_ige(encrypted_answer, key, iv)
319
+
320
+ # Skip hash sum (20 bytes) and parse server_dh_inner EXACTLY like Telethon
321
+ offset = 20
322
+ server_dh_data = plain_text_answer[offset..-1]
323
+
324
+ # Parse ServerDHInnerData like Telethon (authenticator.py line 118)
325
+ server_dh_inner = parse_server_dh_inner_data(server_dh_data)
326
+
327
+ # Calculate time_offset like Telethon (authenticator.py line 131)
328
+ server_time = server_dh_inner[:server_time]
329
+ time_offset = server_time - Time.now.to_i
330
+ Rails.logger.info "⏰ Server time: #{server_time}, local time: #{Time.now.to_i}, time_offset: #{time_offset}"
331
+
332
+ # Generate random b (256 bytes) like Telethon
333
+ b_bytes = SecureRandom.random_bytes(256)
334
+ b = Serialization.bytes_to_int(b_bytes, 'big', signed: false)
335
+
336
+ # Extract DH parameters from server like Telethon (authenticator.py lines 128-130)
337
+ g = server_dh_inner[:g]
338
+ dh_prime_bytes = server_dh_inner[:dh_prime]
339
+ dh_prime = Serialization.bytes_to_int(dh_prime_bytes, 'big', signed: false)
340
+
341
+ # Calculate g_b = g^b mod dh_prime
342
+ g_b = Crypto::ModularArithmetic.pow(g, b, dh_prime)
343
+
344
+ # Extract g_a from server DH inner data (already parsed)
345
+ g_a_bytes = server_dh_inner[:g_a]
346
+ g_a = Serialization.bytes_to_int(g_a_bytes, 'big', signed: false)
347
+
348
+ # Calculate auth_key = g_a^b mod dh_prime (EXACTLY like Telethon)
349
+ gab = Crypto::ModularArithmetic.pow(g_a, b, dh_prime)
350
+
351
+ # Prepare client DH Inner Data
352
+ client_dh_inner = ''
353
+ client_dh_inner += [0x6643b654].pack('L<') # ClientDHInnerData constructor
354
+ client_dh_inner += Serialization.serialize_int128(res_pq_data[:nonce]) # nonce
355
+ client_dh_inner += Serialization.serialize_int128(res_pq_data[:server_nonce]) # server_nonce
356
+ client_dh_inner += [0].pack('q<') # retry_id (always 0)
357
+
358
+ # g_b as big-endian byte array
359
+ g_b_bytes = Serialization.integer_to_byte_array(g_b, signed: false)
360
+ client_dh_inner += Serialization.pack_tl_string(g_b_bytes)
361
+
362
+ client_dh_inner_hashed = Digest::SHA1.digest(client_dh_inner) + client_dh_inner
363
+ client_dh_encrypted = Crypto.encrypt_ige(client_dh_inner_hashed, key, iv)
364
+
365
+ # Send SetClientDHParamsRequest
366
+ set_client_dh_params_data = ''
367
+ set_client_dh_params_data += [0xf5045f1f].pack('L<') # SetClientDHParams constructor
368
+ set_client_dh_params_data += Serialization.serialize_int128(res_pq_data[:nonce])
369
+ set_client_dh_params_data += Serialization.serialize_int128(res_pq_data[:server_nonce])
370
+ set_client_dh_params_data += Serialization.pack_tl_string(client_dh_encrypted)
371
+
372
+ Rails.logger.info '📤 Sending SetClientDHParams...'
373
+ dh_gen_response = plain_sender.send(set_client_dh_params_data)
374
+
375
+ return nil unless dh_gen_response && dh_gen_response.length >= 4
376
+
377
+ # Parse response
378
+ constructor = dh_gen_response[0, 4].unpack1('L<')
379
+ Rails.logger.info "🔍 DH gen response constructor: 0x#{constructor.to_s(16)}"
380
+
381
+ case constructor
382
+ when 0x3bcbf734 # dh_gen_ok
383
+ Rails.logger.info '✅ Got dh_gen_ok'
384
+ # Generate auth_key from gab
385
+ auth_key_bytes = Serialization.integer_to_byte_array(gab, signed: false)
386
+ # Pad or truncate to 256 bytes
387
+ if auth_key_bytes.length < 256
388
+ auth_key_bytes = ("\x00" * (256 - auth_key_bytes.length)) + auth_key_bytes
389
+ elsif auth_key_bytes.length > 256
390
+ auth_key_bytes = auth_key_bytes[-256..-1]
391
+ end
392
+
393
+ Rails.logger.info "🔑 Generated auth_key from DH exchange: #{auth_key_bytes.length} bytes"
394
+
395
+ # Return both auth_key and time_offset like Telethon (authenticator.py line 200)
396
+ { auth_key: auth_key_bytes, time_offset: time_offset }
397
+ when 0x46dc1fb9 # dh_gen_retry
398
+ Rails.logger.error '❌ Got dh_gen_retry - need retry logic'
399
+ nil
400
+ when 0xa69dae02 # dh_gen_fail
401
+ Rails.logger.error '❌ Got dh_gen_fail'
402
+ nil
403
+ else
404
+ Rails.logger.error "❌ Unknown DH gen response: 0x#{constructor.to_s(16)}"
405
+ nil
406
+ end
407
+ end
408
+
409
+ # Parse ServerDHInnerData using Telethon BinaryReader
410
+ def self.parse_server_dh_inner_data(data)
411
+ Rails.logger.debug "🔍 Parsing ServerDHInnerData: #{data.length} bytes"
412
+ Rails.logger.debug "🔍 ServerDHInnerData HEX: #{data.unpack('H*')[0]}"
413
+
414
+ require_relative 'binary_reader'
415
+
416
+ reader = Telegram::BinaryReader.new(data)
417
+ server_dh_inner = reader.tgread_object
418
+
419
+ Rails.logger.info "✅ Parsed ServerDHInnerData successfully with Telethon BinaryReader"
420
+ Rails.logger.info "🔍 Constructor: 0x#{server_dh_inner[:constructor_id].to_s(16)}"
421
+ Rails.logger.info "🔍 g: #{server_dh_inner[:g]}"
422
+ Rails.logger.info "🔍 dh_prime length: #{server_dh_inner[:dh_prime].length} bytes"
423
+ Rails.logger.info "🔍 g_a length: #{server_dh_inner[:g_a].length} bytes"
424
+ Rails.logger.info "⏰ server_time: #{server_dh_inner[:server_time]} (#{Time.at(server_dh_inner[:server_time])})"
425
+
426
+ {
427
+ server_time: server_dh_inner[:server_time],
428
+ g: server_dh_inner[:g],
429
+ dh_prime: server_dh_inner[:dh_prime],
430
+ g_a: server_dh_inner[:g_a]
431
+ }
432
+ rescue => e
433
+ Rails.logger.error "❌ Error parsing ServerDHInnerData: #{e.message}"
434
+ Rails.logger.error "❌ Backtrace: #{e.backtrace.join("\n")}"
435
+ nil
436
+ end
437
+ end
438
+ end
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Telegram
4
+ # Ruby port of Telethon's BinaryReader
5
+ # Based on telethon/extensions/binaryreader.py
6
+ class BinaryReader
7
+ def initialize(data)
8
+ @stream = data || ''
9
+ @position = 0
10
+ @last = nil # Should come in handy to spot -404 errors
11
+ end
12
+
13
+ # All numbers are written as little endian.
14
+ # https://core.telegram.org/mtproto
15
+ def read_byte
16
+ raise BufferError.new("No more data left to read (need 1, got 0)") if @position >= @stream.length
17
+ value = @stream[@position].unpack1('C')
18
+ @position += 1
19
+ value
20
+ end
21
+
22
+ def read_int(signed: true)
23
+ length = 4
24
+ if @position + length > @stream.length
25
+ raise BufferError.new("No more data left to read (need #{length}, got #{@stream.length - @position})")
26
+ end
27
+
28
+ format = signed ? 'l<' : 'L<'
29
+ value = @stream[@position, length].unpack1(format)
30
+ @position += length
31
+ value
32
+ end
33
+
34
+ def read_long(signed: true)
35
+ length = 8
36
+ if @position + length > @stream.length
37
+ raise BufferError.new("No more data left to read (need #{length}, got #{@stream.length - @position})")
38
+ end
39
+
40
+ format = signed ? 'q<' : 'Q<'
41
+ value = @stream[@position, length].unpack1(format)
42
+ @position += length
43
+ value
44
+ end
45
+
46
+ def read(length)
47
+ if length >= 0
48
+ if @position + length > @stream.length
49
+ raise BufferError.new("No more data left to read (need #{length}, got #{@stream.length - @position})")
50
+ end
51
+ result = @stream[@position, length]
52
+ @position += length
53
+ else
54
+ result = @stream[@position..-1]
55
+ @position += result.length
56
+ end
57
+
58
+ @last = result
59
+ result
60
+ end
61
+
62
+ # Telegram-specific methods
63
+
64
+ def tgread_bytes
65
+ # Reads a Telegram-encoded byte array, without the need of specifying its length.
66
+ first_byte = read_byte
67
+
68
+ if first_byte == 254
69
+ # Long format: 254 + 3 bytes for length
70
+ length = read_byte | (read_byte << 8) | (read_byte << 16)
71
+ padding = length % 4
72
+ else
73
+ # Short format: first byte is length
74
+ length = first_byte
75
+ padding = (length + 1) % 4
76
+ end
77
+
78
+ data = read(length)
79
+ if padding > 0
80
+ padding = 4 - padding
81
+ read(padding) # Skip padding
82
+ end
83
+
84
+ data
85
+ end
86
+
87
+ def tgread_string
88
+ # Reads a Telegram-encoded string.
89
+ tgread_bytes.force_encoding('UTF-8')
90
+ end
91
+
92
+ def tgread_bool
93
+ # Reads a Telegram boolean value.
94
+ value = read_int(signed: false)
95
+ case value
96
+ when 0x997275b5 # boolTrue
97
+ true
98
+ when 0xbc799737 # boolFalse
99
+ false
100
+ else
101
+ raise "Invalid boolean code #{value.to_s(16)}"
102
+ end
103
+ end
104
+
105
+ def tgread_object
106
+ # Reads a Telegram object.
107
+ constructor_id = read_int(signed: false)
108
+
109
+ case constructor_id
110
+ when 0xb5890dba # server_DH_inner_data
111
+ tgread_server_dh_inner_data
112
+ when 0x997275b5 # boolTrue
113
+ true
114
+ when 0xbc799737 # boolFalse
115
+ false
116
+ when 0x1cb5c415 # Vector
117
+ count = read_int(signed: false)
118
+ (0...count).map { tgread_object }
119
+ else
120
+ raise "Unknown TL constructor: 0x#{constructor_id.to_s(16)}"
121
+ end
122
+ end
123
+
124
+ def tell_position
125
+ @position
126
+ end
127
+
128
+ def seek(offset)
129
+ @position = offset
130
+ end
131
+
132
+ private
133
+
134
+ def tgread_server_dh_inner_data
135
+ # server_DH_inner_data#b5890dba nonce:int128 server_nonce:int128 g:int dh_prime:string g_a:string server_time:int
136
+ nonce = read(16)
137
+ server_nonce = read(16)
138
+ g = read_int(signed: false)
139
+ dh_prime = tgread_bytes
140
+ g_a = tgread_bytes
141
+ server_time = read_int(signed: true)
142
+
143
+ {
144
+ constructor_id: 0xb5890dba,
145
+ nonce: nonce,
146
+ server_nonce: server_nonce,
147
+ g: g,
148
+ dh_prime: dh_prime,
149
+ g_a: g_a,
150
+ server_time: server_time
151
+ }
152
+ end
153
+ end
154
+
155
+ class BufferError < StandardError; end
156
+ end