소스 검색

mtglib/dcprobe: unauthenticated DC verification probe

New leaf package that performs the first step of the MTProto handshake
(req_pq_multi -> resPQ) over the existing obfuscated2 transport. No
auth_key is generated; no long-lived state is introduced. Two TL
messages, one round-trip, no new dependencies.

A generic listener cannot fake the reply because it must echo back our
random nonce in resPQ.

Used by the doctor command in a follow-up commit to distinguish a real
Telegram DC from a generic TCP listener bound to port 443.
pull/496/head
Alexey Dolotov 20 시간 전
부모
커밋
3c8adff364
2개의 변경된 파일291개의 추가작업 그리고 0개의 파일을 삭제
  1. 181
    0
      mtglib/dcprobe/probe.go
  2. 110
    0
      mtglib/dcprobe/probe_test.go

+ 181
- 0
mtglib/dcprobe/probe.go 파일 보기

@@ -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 }

+ 110
- 0
mtglib/dcprobe/probe_test.go 파일 보기

@@ -0,0 +1,110 @@
1
+package dcprobe_test
2
+
3
+import (
4
+	"context"
5
+	"errors"
6
+	"io"
7
+	"net"
8
+	"os"
9
+	"testing"
10
+	"time"
11
+
12
+	"github.com/9seconds/mtg/v2/mtglib/dcprobe"
13
+)
14
+
15
+// TestProbeAgainstTelegramDCs makes outbound TCP connections to public
16
+// Telegram DCs. Skipped by default; opt-in with MTG_PROBE_NETWORK=1.
17
+func TestProbeAgainstTelegramDCs(t *testing.T) {
18
+	if os.Getenv("MTG_PROBE_NETWORK") != "1" {
19
+		t.Skip("skipping network probe (set MTG_PROBE_NETWORK=1 to enable)")
20
+	}
21
+
22
+	cases := []struct {
23
+		dc   int
24
+		addr string
25
+	}{
26
+		{1, "149.154.175.50:443"},
27
+		{2, "149.154.167.51:443"},
28
+		{2, "95.161.76.100:443"},
29
+		{3, "149.154.175.100:443"},
30
+		{4, "149.154.167.91:443"},
31
+		{5, "149.154.171.5:443"},
32
+		{1, "[2001:b28:f23d:f001::a]:443"},
33
+		{2, "[2001:67c:04e8:f002::a]:443"},
34
+	}
35
+
36
+	for _, tc := range cases {
37
+		t.Run(tc.addr, func(t *testing.T) {
38
+			ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
39
+			defer cancel()
40
+
41
+			conn, err := (&net.Dialer{}).DialContext(ctx, "tcp", tc.addr)
42
+			if err != nil {
43
+				t.Fatalf("dial: %v", err)
44
+			}
45
+			t.Cleanup(func() { _ = conn.Close() })
46
+
47
+			rtt, err := dcprobe.Probe(ctx, conn, tc.dc)
48
+			if err != nil {
49
+				t.Fatalf("probe DC %d: %v", tc.dc, err)
50
+			}
51
+			t.Logf("DC %d (%s): rtt=%s", tc.dc, tc.addr, rtt)
52
+		})
53
+	}
54
+}
55
+
56
+// TestProbeRejectsMisbehavingPeer connects to an in-process listener that
57
+// accepts the obfs2 handshake, then writes back arbitrary bytes. With
58
+// overwhelming probability the decrypted reply fails one of: frame-length
59
+// bounds, resPQ constructor, or nonce echo. All three paths wrap
60
+// ErrNotTelegram, so we assert errors.Is.
61
+func TestProbeRejectsMisbehavingPeer(t *testing.T) {
62
+	ln, err := net.Listen("tcp", "127.0.0.1:0")
63
+	if err != nil {
64
+		t.Fatal(err)
65
+	}
66
+	t.Cleanup(func() { _ = ln.Close() })
67
+
68
+	go func() {
69
+		c, err := ln.Accept()
70
+		if err != nil {
71
+			return
72
+		}
73
+		defer c.Close() //nolint: errcheck
74
+
75
+		// Discard the 64-byte obfs2 handshake the client sends.
76
+		var hs [64]byte
77
+		if _, err := io.ReadFull(c, hs[:]); err != nil {
78
+			return
79
+		}
80
+		// Write enough garbage to satisfy any plausible respLen the client
81
+		// might decode (we cap at maxResPQFrame=256 in probe.go). Whatever
82
+		// the client decrypts will fail constructor or nonce verification.
83
+		junk := make([]byte, 512)
84
+		for i := range junk {
85
+			junk[i] = byte(i)
86
+		}
87
+		_, _ = c.Write(junk)
88
+		// Keep the conn open until the client closes it (avoids racing the
89
+		// client's read against our close).
90
+		_, _ = io.Copy(io.Discard, c)
91
+	}()
92
+
93
+	ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
94
+	defer cancel()
95
+
96
+	conn, err := (&net.Dialer{}).DialContext(ctx, "tcp", ln.Addr().String())
97
+	if err != nil {
98
+		t.Fatal(err)
99
+	}
100
+	t.Cleanup(func() { _ = conn.Close() })
101
+
102
+	_, err = dcprobe.Probe(ctx, conn, 2)
103
+	if err == nil {
104
+		t.Fatal("expected ErrNotTelegram, got nil")
105
+	}
106
+	if !errors.Is(err, dcprobe.ErrNotTelegram) {
107
+		t.Fatalf("expected errors.Is(err, ErrNotTelegram) to be true, got: %v", err)
108
+	}
109
+	t.Logf("rejected: %v", err)
110
+}

Loading…
취소
저장