|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+// Package dcprobe verifies that a TCP endpoint is a real Telegram DC by
|
|
|
2
|
+// performing the unauthenticated first step of the MTProto handshake
|
|
|
3
|
+// (req_pq_multi -> resPQ) on top of mtg's existing obfuscated2 transport.
|
|
|
4
|
+//
|
|
|
5
|
+// No auth_key is generated; no long-lived state is introduced. Two TL
|
|
|
6
|
+// messages, one round-trip. A generic listener cannot fake the reply
|
|
|
7
|
+// because it must echo back our random nonce in resPQ.
|
|
|
8
|
+//
|
|
|
9
|
+// References:
|
|
|
10
|
+// - https://core.telegram.org/mtproto/auth_key (handshake step 1)
|
|
|
11
|
+// - https://core.telegram.org/schema/mtproto (TL schema)
|
|
|
12
|
+// - https://core.telegram.org/mtproto/mtproto-transports#padded-intermediate
|
|
|
13
|
+package dcprobe
|
|
|
14
|
+
|
|
|
15
|
+import (
|
|
|
16
|
+ "bytes"
|
|
|
17
|
+ "context"
|
|
|
18
|
+ "crypto/rand"
|
|
|
19
|
+ "encoding/binary"
|
|
|
20
|
+ "errors"
|
|
|
21
|
+ "fmt"
|
|
|
22
|
+ "io"
|
|
|
23
|
+ "net"
|
|
|
24
|
+ "time"
|
|
|
25
|
+
|
|
|
26
|
+ "github.com/9seconds/mtg/v2/essentials"
|
|
|
27
|
+ "github.com/9seconds/mtg/v2/mtglib/obfuscation"
|
|
|
28
|
+)
|
|
|
29
|
+
|
|
|
30
|
+// MTProto wire constants (https://core.telegram.org/schema/mtproto).
|
|
|
31
|
+//
|
|
|
32
|
+// req_pq_multi#be7e8ef1 nonce:int128 = ResPQ;
|
|
|
33
|
+// resPQ#05162463 nonce:int128 server_nonce:int128 pq:string
|
|
|
34
|
+// server_public_key_fingerprints:Vector<long> = ResPQ;
|
|
|
35
|
+const (
|
|
|
36
|
+ ctorReqPQMulti uint32 = 0xbe7e8ef1
|
|
|
37
|
+ ctorResPQ uint32 = 0x05162463
|
|
|
38
|
+
|
|
|
39
|
+ // Minimum legal resPQ frame: 20-byte unencrypted-message envelope +
|
|
|
40
|
+ // 4-byte ctor + 16-byte nonce echo. Anything below cannot be a resPQ.
|
|
|
41
|
+ minResPQFrame = 20 + 4 + 16
|
|
|
42
|
+ // Upper bound: real resPQ replies are ~84 bytes (envelope + ~64-byte
|
|
|
43
|
+ // payload). 256 is comfortable headroom; anything beyond is hostile or
|
|
|
44
|
+ // not Telegram.
|
|
|
45
|
+ maxResPQFrame = 256
|
|
|
46
|
+)
|
|
|
47
|
+
|
|
|
48
|
+// Probe sends req_pq_multi over an obfuscated2 + padded-intermediate transport
|
|
|
49
|
+// and verifies that the peer replies with a matching resPQ.
|
|
|
50
|
+//
|
|
|
51
|
+// conn must be a freshly opened reliable byte stream (typically TCP) to a
|
|
|
52
|
+// Telegram DC, but a SOCKS/proxy-wrapped net.Conn works just as well — Probe
|
|
|
53
|
+// adapts whatever it gets to the half-close interface mtg's obfuscator
|
|
|
54
|
+// requires. Probe does NOT close conn — the caller does. dc is the DC number
|
|
|
55
|
+// (1..5) that gets baked into the obfuscated2 handshake frame.
|
|
|
56
|
+//
|
|
|
57
|
+// The returned duration is the round-trip from "first byte sent after the
|
|
|
58
|
+// obfs handshake" to "resPQ frame fully read".
|
|
|
59
|
+func Probe(ctx context.Context, conn net.Conn, dc int) (time.Duration, error) {
|
|
|
60
|
+ if deadline, ok := ctx.Deadline(); ok {
|
|
|
61
|
+ _ = conn.SetDeadline(deadline)
|
|
|
62
|
+ defer func() { _ = conn.SetDeadline(time.Time{}) }()
|
|
|
63
|
+ }
|
|
|
64
|
+
|
|
|
65
|
+ // Honour ctx cancellation as well as its deadline: a parent ctx that is
|
|
|
66
|
+ // canceled (without an earlier deadline expiring) would otherwise let
|
|
|
67
|
+ // Probe block on an in-flight Read until the deadline. Forcing the
|
|
|
68
|
+ // deadline to "now" makes the next syscall return an i/o timeout error
|
|
|
69
|
+ // that Probe wraps and surfaces.
|
|
|
70
|
+ stop := context.AfterFunc(ctx, func() {
|
|
|
71
|
+ _ = conn.SetDeadline(time.Now())
|
|
|
72
|
+ })
|
|
|
73
|
+ defer stop()
|
|
|
74
|
+
|
|
|
75
|
+ // 1. obfuscated2 handshake. Empty Secret = no MTProxy secret mixing,
|
|
|
76
|
+ // which is how mtg itself talks to a DC (see mtglib/proxy.go).
|
|
|
77
|
+ obfsConn, err := obfuscation.Obfuscator{}.SendHandshake(adaptConn(conn), dc)
|
|
|
78
|
+ if err != nil {
|
|
|
79
|
+ return 0, fmt.Errorf("obfuscated2 handshake: %w", err)
|
|
|
80
|
+ }
|
|
|
81
|
+
|
|
|
82
|
+ // 2. build req_pq_multi TL payload: 4-byte LE constructor + 16-byte nonce.
|
|
|
83
|
+ var nonce [16]byte
|
|
|
84
|
+ if _, err := rand.Read(nonce[:]); err != nil {
|
|
|
85
|
+ return 0, fmt.Errorf("read nonce: %w", err)
|
|
|
86
|
+ }
|
|
|
87
|
+ tlBody := make([]byte, 4+16)
|
|
|
88
|
+ binary.LittleEndian.PutUint32(tlBody[:4], ctorReqPQMulti)
|
|
|
89
|
+ copy(tlBody[4:], nonce[:])
|
|
|
90
|
+
|
|
|
91
|
+ // 3. wrap in an MTProto unencrypted message envelope (per
|
|
|
92
|
+ // https://core.telegram.org/mtproto/description#unencrypted-message):
|
|
|
93
|
+ // auth_key_id:long(=0) | message_id:long | message_data_length:int | message_data:bytes
|
|
|
94
|
+ // Without this envelope the DC silently drops the connection.
|
|
|
95
|
+ msg := make([]byte, 8+8+4+len(tlBody))
|
|
|
96
|
+ // auth_key_id = 0 (already zeroed by make)
|
|
|
97
|
+ binary.LittleEndian.PutUint64(msg[8:16], generateMessageID())
|
|
|
98
|
+ binary.LittleEndian.PutUint32(msg[16:20], uint32(len(tlBody)))
|
|
|
99
|
+ copy(msg[20:], tlBody)
|
|
|
100
|
+
|
|
|
101
|
+ // 4. wrap in a padded-intermediate frame: length(LE) + msg.
|
|
|
102
|
+ // Padding is allowed [0..15] but not required when len(msg) % 4 == 0.
|
|
|
103
|
+ frame := make([]byte, 4+len(msg))
|
|
|
104
|
+ binary.LittleEndian.PutUint32(frame[:4], uint32(len(msg)))
|
|
|
105
|
+ copy(frame[4:], msg)
|
|
|
106
|
+
|
|
|
107
|
+ start := time.Now()
|
|
|
108
|
+ if _, err := obfsConn.Write(frame); err != nil {
|
|
|
109
|
+ return 0, fmt.Errorf("write req_pq_multi: %w", err)
|
|
|
110
|
+ }
|
|
|
111
|
+
|
|
|
112
|
+ // 5. read padded-intermediate reply: length, then that many bytes.
|
|
|
113
|
+ // The reply is itself an MTProto unencrypted message (same envelope as
|
|
|
114
|
+ // what we sent), so we must skip 20 bytes to get to the resPQ TL.
|
|
|
115
|
+ var lenBuf [4]byte
|
|
|
116
|
+ if _, err := io.ReadFull(obfsConn, lenBuf[:]); err != nil {
|
|
|
117
|
+ return 0, fmt.Errorf("read frame length: %w", err)
|
|
|
118
|
+ }
|
|
|
119
|
+ respLen := binary.LittleEndian.Uint32(lenBuf[:])
|
|
|
120
|
+ if respLen < minResPQFrame {
|
|
|
121
|
+ return 0, fmt.Errorf("%w: resPQ frame too short (%d bytes)", ErrNotTelegram, respLen)
|
|
|
122
|
+ }
|
|
|
123
|
+ if respLen > maxResPQFrame {
|
|
|
124
|
+ return 0, fmt.Errorf("%w: resPQ frame too large (%d bytes, max %d)", ErrNotTelegram, respLen, maxResPQFrame)
|
|
|
125
|
+ }
|
|
|
126
|
+ resp := make([]byte, respLen)
|
|
|
127
|
+ if _, err := io.ReadFull(obfsConn, resp); err != nil {
|
|
|
128
|
+ return 0, fmt.Errorf("read resPQ frame: %w", err)
|
|
|
129
|
+ }
|
|
|
130
|
+ rtt := time.Since(start)
|
|
|
131
|
+
|
|
|
132
|
+ // 6. unwrap the MTProto envelope: skip auth_key_id(8) + message_id(8) +
|
|
|
133
|
+ // message_data_length(4) = 20 bytes.
|
|
|
134
|
+ tlResp := resp[20:]
|
|
|
135
|
+
|
|
|
136
|
+ // 7. verify constructor and nonce echo. We deliberately do not parse
|
|
|
137
|
+ // server_nonce, pq, or fingerprints — they are not needed to prove
|
|
|
138
|
+ // the peer can speak MTProto.
|
|
|
139
|
+ if got := binary.LittleEndian.Uint32(tlResp[:4]); got != ctorResPQ {
|
|
|
140
|
+ return rtt, fmt.Errorf("%w: got constructor 0x%08x, want resPQ 0x%08x", ErrNotTelegram, got, ctorResPQ)
|
|
|
141
|
+ }
|
|
|
142
|
+ if !bytes.Equal(tlResp[4:4+16], nonce[:]) {
|
|
|
143
|
+ return rtt, fmt.Errorf("%w: nonce echo mismatch", ErrNotTelegram)
|
|
|
144
|
+ }
|
|
|
145
|
+
|
|
|
146
|
+ return rtt, nil
|
|
|
147
|
+}
|
|
|
148
|
+
|
|
|
149
|
+// generateMessageID returns an MTProto message_id roughly synchronized with
|
|
|
150
|
+// server time, with the lower 2 bits cleared (client-to-server requests).
|
|
|
151
|
+// See https://core.telegram.org/mtproto/description#message-identifier-msg-id.
|
|
|
152
|
+func generateMessageID() uint64 {
|
|
|
153
|
+ nano := uint64(time.Now().UnixNano())
|
|
|
154
|
+ sec := nano / 1_000_000_000
|
|
|
155
|
+ nsInSec := nano % 1_000_000_000
|
|
|
156
|
+ subsec := (nsInSec << 32) / 1_000_000_000
|
|
|
157
|
+ id := (sec << 32) | subsec
|
|
|
158
|
+ return id &^ 3
|
|
|
159
|
+}
|
|
|
160
|
+
|
|
|
161
|
+// ErrNotTelegram is returned (wrapped) when the peer's reply is not a
|
|
|
162
|
+// well-formed resPQ matching our nonce. Use errors.Is to distinguish
|
|
|
163
|
+// "the TCP connection was OK but the peer is not a Telegram DC" from
|
|
|
164
|
+// transport errors.
|
|
|
165
|
+var ErrNotTelegram = errors.New("peer did not respond with a matching resPQ")
|
|
|
166
|
+
|
|
|
167
|
+// adaptConn returns conn as essentials.Conn if it already satisfies the
|
|
|
168
|
+// interface (typically *net.TCPConn), otherwise wraps it with no-op
|
|
|
169
|
+// CloseRead/CloseWrite. mtg's obfuscator only ever calls Read/Write/Close,
|
|
|
170
|
+// so the no-ops are safe.
|
|
|
171
|
+func adaptConn(conn net.Conn) essentials.Conn {
|
|
|
172
|
+ if ec, ok := conn.(essentials.Conn); ok {
|
|
|
173
|
+ return ec
|
|
|
174
|
+ }
|
|
|
175
|
+ return halfCloseShim{Conn: conn}
|
|
|
176
|
+}
|
|
|
177
|
+
|
|
|
178
|
+type halfCloseShim struct{ net.Conn }
|
|
|
179
|
+
|
|
|
180
|
+func (halfCloseShim) CloseRead() error { return nil }
|
|
|
181
|
+func (halfCloseShim) CloseWrite() error { return nil }
|