| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181 |
- // Package dcprobe verifies that a TCP endpoint is a real Telegram DC by
- // performing the unauthenticated first step of the MTProto handshake
- // (req_pq_multi -> resPQ) on top of mtg's existing obfuscated2 transport.
- //
- // No auth_key is generated; no long-lived state is introduced. Two TL
- // messages, one round-trip. A generic listener cannot fake the reply
- // because it must echo back our random nonce in resPQ.
- //
- // References:
- // - https://core.telegram.org/mtproto/auth_key (handshake step 1)
- // - https://core.telegram.org/schema/mtproto (TL schema)
- // - https://core.telegram.org/mtproto/mtproto-transports#padded-intermediate
- package dcprobe
-
- import (
- "bytes"
- "context"
- "crypto/rand"
- "encoding/binary"
- "errors"
- "fmt"
- "io"
- "net"
- "time"
-
- "github.com/9seconds/mtg/v2/essentials"
- "github.com/9seconds/mtg/v2/mtglib/obfuscation"
- )
-
- // MTProto wire constants (https://core.telegram.org/schema/mtproto).
- //
- // req_pq_multi#be7e8ef1 nonce:int128 = ResPQ;
- // resPQ#05162463 nonce:int128 server_nonce:int128 pq:string
- // server_public_key_fingerprints:Vector<long> = ResPQ;
- const (
- ctorReqPQMulti uint32 = 0xbe7e8ef1
- ctorResPQ uint32 = 0x05162463
-
- // Minimum legal resPQ frame: 20-byte unencrypted-message envelope +
- // 4-byte ctor + 16-byte nonce echo. Anything below cannot be a resPQ.
- minResPQFrame = 20 + 4 + 16
- // Upper bound: real resPQ replies are ~84 bytes (envelope + ~64-byte
- // payload). 256 is comfortable headroom; anything beyond is hostile or
- // not Telegram.
- maxResPQFrame = 256
- )
-
- // Probe sends req_pq_multi over an obfuscated2 + padded-intermediate transport
- // and verifies that the peer replies with a matching resPQ.
- //
- // conn must be a freshly opened reliable byte stream (typically TCP) to a
- // Telegram DC, but a SOCKS/proxy-wrapped net.Conn works just as well — Probe
- // adapts whatever it gets to the half-close interface mtg's obfuscator
- // requires. Probe does NOT close conn — the caller does. dc is the DC number
- // (1..5) that gets baked into the obfuscated2 handshake frame.
- //
- // The returned duration is the round-trip from "first byte sent after the
- // obfs handshake" to "resPQ frame fully read".
- func Probe(ctx context.Context, conn net.Conn, dc int) (time.Duration, error) {
- if deadline, ok := ctx.Deadline(); ok {
- _ = conn.SetDeadline(deadline)
- defer func() { _ = conn.SetDeadline(time.Time{}) }()
- }
-
- // Honour ctx cancellation as well as its deadline: a parent ctx that is
- // canceled (without an earlier deadline expiring) would otherwise let
- // Probe block on an in-flight Read until the deadline. Forcing the
- // deadline to "now" makes the next syscall return an i/o timeout error
- // that Probe wraps and surfaces.
- stop := context.AfterFunc(ctx, func() {
- _ = conn.SetDeadline(time.Now())
- })
- defer stop()
-
- // 1. obfuscated2 handshake. Empty Secret = no MTProxy secret mixing,
- // which is how mtg itself talks to a DC (see mtglib/proxy.go).
- obfsConn, err := obfuscation.Obfuscator{}.SendHandshake(adaptConn(conn), dc)
- if err != nil {
- return 0, fmt.Errorf("obfuscated2 handshake: %w", err)
- }
-
- // 2. build req_pq_multi TL payload: 4-byte LE constructor + 16-byte nonce.
- var nonce [16]byte
- if _, err := rand.Read(nonce[:]); err != nil {
- return 0, fmt.Errorf("read nonce: %w", err)
- }
- tlBody := make([]byte, 4+16)
- binary.LittleEndian.PutUint32(tlBody[:4], ctorReqPQMulti)
- copy(tlBody[4:], nonce[:])
-
- // 3. wrap in an MTProto unencrypted message envelope (per
- // https://core.telegram.org/mtproto/description#unencrypted-message):
- // auth_key_id:long(=0) | message_id:long | message_data_length:int | message_data:bytes
- // Without this envelope the DC silently drops the connection.
- msg := make([]byte, 8+8+4+len(tlBody))
- // auth_key_id = 0 (already zeroed by make)
- binary.LittleEndian.PutUint64(msg[8:16], generateMessageID())
- binary.LittleEndian.PutUint32(msg[16:20], uint32(len(tlBody)))
- copy(msg[20:], tlBody)
-
- // 4. wrap in a padded-intermediate frame: length(LE) + msg.
- // Padding is allowed [0..15] but not required when len(msg) % 4 == 0.
- frame := make([]byte, 4+len(msg))
- binary.LittleEndian.PutUint32(frame[:4], uint32(len(msg)))
- copy(frame[4:], msg)
-
- start := time.Now()
- if _, err := obfsConn.Write(frame); err != nil {
- return 0, fmt.Errorf("write req_pq_multi: %w", err)
- }
-
- // 5. read padded-intermediate reply: length, then that many bytes.
- // The reply is itself an MTProto unencrypted message (same envelope as
- // what we sent), so we must skip 20 bytes to get to the resPQ TL.
- var lenBuf [4]byte
- if _, err := io.ReadFull(obfsConn, lenBuf[:]); err != nil {
- return 0, fmt.Errorf("read frame length: %w", err)
- }
- respLen := binary.LittleEndian.Uint32(lenBuf[:])
- if respLen < minResPQFrame {
- return 0, fmt.Errorf("%w: resPQ frame too short (%d bytes)", ErrNotTelegram, respLen)
- }
- if respLen > maxResPQFrame {
- return 0, fmt.Errorf("%w: resPQ frame too large (%d bytes, max %d)", ErrNotTelegram, respLen, maxResPQFrame)
- }
- resp := make([]byte, respLen)
- if _, err := io.ReadFull(obfsConn, resp); err != nil {
- return 0, fmt.Errorf("read resPQ frame: %w", err)
- }
- rtt := time.Since(start)
-
- // 6. unwrap the MTProto envelope: skip auth_key_id(8) + message_id(8) +
- // message_data_length(4) = 20 bytes.
- tlResp := resp[20:]
-
- // 7. verify constructor and nonce echo. We deliberately do not parse
- // server_nonce, pq, or fingerprints — they are not needed to prove
- // the peer can speak MTProto.
- if got := binary.LittleEndian.Uint32(tlResp[:4]); got != ctorResPQ {
- return rtt, fmt.Errorf("%w: got constructor 0x%08x, want resPQ 0x%08x", ErrNotTelegram, got, ctorResPQ)
- }
- if !bytes.Equal(tlResp[4:4+16], nonce[:]) {
- return rtt, fmt.Errorf("%w: nonce echo mismatch", ErrNotTelegram)
- }
-
- return rtt, nil
- }
-
- // generateMessageID returns an MTProto message_id roughly synchronized with
- // server time, with the lower 2 bits cleared (client-to-server requests).
- // See https://core.telegram.org/mtproto/description#message-identifier-msg-id.
- func generateMessageID() uint64 {
- nano := uint64(time.Now().UnixNano())
- sec := nano / 1_000_000_000
- nsInSec := nano % 1_000_000_000
- subsec := (nsInSec << 32) / 1_000_000_000
- id := (sec << 32) | subsec
- return id &^ 3
- }
-
- // ErrNotTelegram is returned (wrapped) when the peer's reply is not a
- // well-formed resPQ matching our nonce. Use errors.Is to distinguish
- // "the TCP connection was OK but the peer is not a Telegram DC" from
- // transport errors.
- var ErrNotTelegram = errors.New("peer did not respond with a matching resPQ")
-
- // adaptConn returns conn as essentials.Conn if it already satisfies the
- // interface (typically *net.TCPConn), otherwise wraps it with no-op
- // CloseRead/CloseWrite. mtg's obfuscator only ever calls Read/Write/Close,
- // so the no-ops are safe.
- func adaptConn(conn net.Conn) essentials.Conn {
- if ec, ok := conn.(essentials.Conn); ok {
- return ec
- }
- return halfCloseShim{Conn: conn}
- }
-
- type halfCloseShim struct{ net.Conn }
-
- func (halfCloseShim) CloseRead() error { return nil }
- func (halfCloseShim) CloseWrite() error { return nil }
|