init
Publish mtproxy_checker Docker image / test (push) Successful in 19s
Publish mtproxy_checker Docker image / build-and-push (push) Successful in 48s

This commit is contained in:
Denozordec
2026-04-11 00:38:22 +07:00
commit 4905c06b9b
26 changed files with 1887 additions and 0 deletions
+105
View File
@@ -0,0 +1,105 @@
package checker
import (
"context"
"errors"
"fmt"
"io"
"net"
"time"
"mtproxy_checker/internal/faketls"
"mtproxy_checker/internal/mtproxy"
"mtproxy_checker/internal/secret"
)
// ErrProxyClosed indicates the MTProxy dropped the TCP connection right after the probe (Telethon #1134 style).
var ErrProxyClosed = errors.New("mtproxy closed connection after initial payload")
// Check runs the handshake probe for the given parsed secret.
func Check(ctx context.Context, host string, port int, parsed *secret.Parsed, dcID int16) error {
conn, err := dialTCP(ctx, host, port)
if err != nil {
return fmt.Errorf("tcp dial: %w", err)
}
defer conn.Close()
switch parsed.Kind {
case secret.KindEE:
return checkEE(conn, parsed, dcID)
case secret.KindDD:
return checkDD(conn, parsed, dcID)
default:
return fmt.Errorf("unknown secret kind")
}
}
func checkEE(conn net.Conn, p *secret.Parsed, dcID int16) error {
// ee-секрет хранит домен как «сырой» хвост (часто 0xd0 + ASCII hostname). В TLS SNI нужен только hostname,
// как в официальном клиенте Telegram — иначе прокси сбрасывает соединение до ServerHello.
sni := faketls.SNIDomain(p.Domain)
if len(sni) == 0 {
sni = p.Domain
}
ch, err := faketls.BuildTdesktopClientHello(p.Key, sni)
if err != nil {
return fmt.Errorf("fake-tls client hello: %w", err)
}
if _, err := conn.Write(ch.Record); err != nil {
return fmt.Errorf("write client hello: %w", err)
}
resp, err := faketls.ReadServerHello(conn)
if err != nil {
return fmt.Errorf("read server hello: %w", err)
}
if err := faketls.VerifyServerHelloTdesktop(resp, p.Key, ch.RandomField); err != nil {
return fmt.Errorf("verify server hello: %w", err)
}
hdr, _, _, err := mtproxy.InitHeader(p.Key, dcID)
if err != nil {
return fmt.Errorf("mtproxy header: %w", err)
}
if err := faketls.WriteTLSApplicationData(conn, hdr); err != nil {
return fmt.Errorf("write mtproxy header: %w", err)
}
return waitPostPayload(conn)
}
func checkDD(conn net.Conn, p *secret.Parsed, dcID int16) error {
hdr, _, _, err := mtproxy.InitHeader(p.Key, dcID)
if err != nil {
return fmt.Errorf("mtproxy header: %w", err)
}
if _, err := conn.Write(hdr); err != nil {
return fmt.Errorf("write mtproxy header: %w", err)
}
return waitPostPayload(conn)
}
func waitPostPayload(conn net.Conn) error {
deadline := time.Now().Add(2 * time.Second)
buf := make([]byte, 4096)
for time.Now().Before(deadline) {
_ = conn.SetReadDeadline(time.Now().Add(200 * time.Millisecond))
n, err := conn.Read(buf)
if n > 0 {
_ = conn.SetReadDeadline(time.Time{})
return nil
}
if err != nil {
if errors.Is(err, io.EOF) {
_ = conn.SetReadDeadline(time.Time{})
return ErrProxyClosed
}
var ne net.Error
if errors.As(err, &ne) && ne.Timeout() {
continue
}
_ = conn.SetReadDeadline(time.Time{})
return err
}
}
_ = conn.SetReadDeadline(time.Time{})
return nil
}
+19
View File
@@ -0,0 +1,19 @@
package checker
import (
"context"
"fmt"
"net"
"strconv"
)
// dialTCP подключается к host:port так же, как типичное приложение: один вызов Dial к имени хоста.
// Раньше использовался обход AAAA/A с приоритетом IPv4 — это могло выбирать другой бэкенд, чем у ОС/клиента Telegram.
func dialTCP(ctx context.Context, host string, port int) (net.Conn, error) {
var d net.Dialer
c, err := d.DialContext(ctx, "tcp", net.JoinHostPort(host, strconv.Itoa(port)))
if err != nil {
return nil, fmt.Errorf("tcp dial: %w", err)
}
return c, nil
}
+31
View File
@@ -0,0 +1,31 @@
package faketls
// SNIDomain returns hostname bytes for TLS SNI from the raw ee-secret domain suffix.
//
// В ссылке tg:// после 16-байтного ключа часто идёт «хвост» домена: один служебный байт (часто 0xd0)
// и дальше ASCII hostname в hex (например …d0632e61… → "c.api.stepa.one").
// В extension server_name нужно передавать только hostname в ASCII, иначе прокси обрывает сессию.
func SNIDomain(raw []byte) []byte {
if len(raw) >= 2 {
// Типичный префикс Telegram для ASCII-имени после ключа
if raw[0] == 0xd0 && raw[1] >= 0x20 && raw[1] < 0x7f {
return raw[1:]
}
}
// Fallback: пропускаем ведущие не-hostname байты до первой буквы/цифры/точки
i := 0
for i < len(raw) {
b := raw[i]
if (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9') {
break
}
if b == '.' || b == '-' {
break
}
i++
}
if i >= len(raw) {
return raw
}
return raw[i:]
}
+20
View File
@@ -0,0 +1,20 @@
package faketls
import (
"bytes"
"encoding/hex"
"testing"
)
func TestSNIDomain_TelegramD0Prefix(t *testing.T) {
// Domain tail from ee5ba9cf...dd0632e6170692e73746570612e6f6e65 (0xd0 + "c.api.stepa.one" hex)
d, err := hex.DecodeString("d0632e6170692e73746570612e6f6e65")
if err != nil {
t.Fatal(err)
}
got := SNIDomain(d)
want := []byte("c.api.stepa.one")
if !bytes.Equal(got, want) {
t.Fatalf("got %q want %q", got, want)
}
}
+161
View File
@@ -0,0 +1,161 @@
package faketls
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/binary"
"fmt"
"time"
)
// Extensions inside ClientHello must total exactly 403 bytes (TelethonFakeTLS / MTProxy expectation).
const extensionsTotal = 403
// Fixed TLS 1.2 cipher list from telethon-faketls.
var cipherSuites = []byte{
0xfa, 0xfa, 0x13, 0x01, 0x13, 0x02, 0x13, 0x03, 0xc0, 0x2b, 0xc0, 0x2f, 0xc0, 0x2c, 0xc0, 0x30,
0xcc, 0xa9, 0xcc, 0xa8, 0xc0, 0x13, 0xc0, 0x14, 0x00, 0x9c, 0x00, 0x9d, 0x00, 0x2f, 0x00, 0x35,
}
var extBeforeKeyShare = []byte{
0x00, 0x17, 0x00, 0x00,
0xff, 0x01, 0x00, 0x01, 0x00,
0x00, 0x0a, 0x00, 0x0a, 0x00, 0x08, 0xba, 0xba, 0x00, 0x1d, 0x00, 0x17, 0x00, 0x18,
0x00, 0x0b, 0x00, 0x02, 0x01, 0x00,
0x00, 0x23, 0x00, 0x00,
0x00, 0x10, 0x00, 0x0e, 0x00, 0x0c, 0x02, 0x68, 0x32, 0x08, 0x68, 0x74, 0x74, 0x70, 0x2f, 0x31, 0x2e, 0x31,
0x00, 0x05, 0x00, 0x05, 0x01, 0x00, 0x00, 0x00, 0x00,
0x00, 0x0d, 0x00, 0x12, 0x00, 0x10, 0x04, 0x03, 0x08, 0x04, 0x04, 0x01, 0x05, 0x03, 0x08, 0x05, 0x05, 0x01, 0x08, 0x06, 0x06, 0x01,
0x00, 0x12, 0x00, 0x00,
}
var keyShareHeader = []byte{0x00, 0x33, 0x00, 0x2b, 0x00, 0x29, 0xba, 0xba, 0x00, 0x01, 0x00, 0x00, 0x1d, 0x00, 0x20}
var extAfterKeyShare = []byte{
0x00, 0x2d, 0x00, 0x02, 0x01, 0x01,
0x00, 0x2b, 0x00, 0x0b, 0x0a, 0x9a, 0x9a, 0x03, 0x04, 0x03, 0x03, 0x03, 0x02, 0x03, 0x01,
0x00, 0x1b, 0x00, 0x03, 0x02, 0x00, 0x02,
0x1a, 0x1a, 0x00, 0x01, 0x00,
}
func sniExtension(domain []byte) []byte {
entry := make([]byte, 0, 3+len(domain))
entry = append(entry, 0x00)
entry = append(entry, byte(len(domain)>>8), byte(len(domain)))
entry = append(entry, domain...)
list := make([]byte, 0, 2+len(entry))
list = append(list, byte(len(entry)>>8), byte(len(entry)))
list = append(list, entry...)
out := make([]byte, 0, 4+len(list))
out = append(out, 0x00, 0x00)
out = append(out, byte(len(list)>>8), byte(len(list)))
out = append(out, list...)
return out
}
func buildExtensions(domain []byte, pubKey []byte) ([]byte, error) {
if len(pubKey) != 32 {
return nil, fmt.Errorf("key share pubkey: need 32 bytes")
}
var exts bytes.Buffer
exts.Write([]byte{0x4a, 0x4a, 0x00, 0x00})
exts.Write(sniExtension(domain))
exts.Write(extBeforeKeyShare)
exts.Write(keyShareHeader)
exts.Write(pubKey)
exts.Write(extAfterKeyShare)
core := exts.Len()
// padding extension: 00 15 + u16(len) + zeros; total extensions must be 403
padHdr := 4
padData := extensionsTotal - core - padHdr
if padData < 0 {
return nil, fmt.Errorf("mtproxy fake-tls: domain too long for fixed 403-byte extension block (core=%d)", core)
}
exts.Write([]byte{0x00, 0x15})
exts.Write([]byte{byte(padData >> 8), byte(padData)})
for i := 0; i < padData; i++ {
exts.WriteByte(0x00)
}
if exts.Len() != extensionsTotal {
return nil, fmt.Errorf("internal: extensions len %d != %d", exts.Len(), extensionsTotal)
}
return exts.Bytes(), nil
}
// ClientHello is the initial raw TLS record and the 32-byte ClientHello.random field (post-HMAC).
type ClientHello struct {
Record []byte
RandomField []byte
SessionID []byte
}
// BuildClientHello constructs the first wire record (517 bytes) compatible with telethon-faketls.
func BuildClientHello(secretKey []byte, domain []byte) (*ClientHello, error) {
if len(secretKey) != 16 {
return nil, fmt.Errorf("secret key must be 16 bytes")
}
pub, err := GenX25519PublicKey()
if err != nil {
return nil, err
}
sessionID, err := Rand32()
if err != nil {
return nil, err
}
exts, err := buildExtensions(domain, pub)
if err != nil {
return nil, err
}
// ClientHello body = 508 bytes
var body bytes.Buffer
body.Write([]byte{0x03, 0x03})
randomZero := make([]byte, 32)
body.Write(randomZero)
body.WriteByte(32)
body.Write(sessionID)
body.Write([]byte{0x00, 0x20})
body.Write(cipherSuites)
body.WriteByte(0x01)
body.WriteByte(0x00)
body.Write([]byte{byte(extensionsTotal >> 8), byte(extensionsTotal & 0xff)})
body.Write(exts)
if body.Len() != 508 {
return nil, fmt.Errorf("internal: client hello body %d != 508", body.Len())
}
handshake := make([]byte, 0, 4+508)
handshake = append(handshake, 0x01)
handshake = append(handshake, byte(508>>16), byte((508>>8)&0xff), byte(508&0xff))
handshake = append(handshake, body.Bytes()...)
record := make([]byte, 0, 5+len(handshake))
record = append(record, 0x16, 0x03, 0x01)
record = binary.BigEndian.AppendUint16(record, uint16(len(handshake)))
record = append(record, handshake...)
randomField := computeRandomField(secretKey, record)
copy(record[11:43], randomField)
return &ClientHello{Record: record, RandomField: randomField, SessionID: sessionID}, nil
}
func computeRandomField(secretKey []byte, record []byte) []byte {
msg := make([]byte, 0, len(record))
msg = append(msg, record[:11]...)
msg = append(msg, make([]byte, 32)...)
msg = append(msg, record[43:]...)
mac := hmac.New(sha256.New, secretKey)
mac.Write(msg)
digest := mac.Sum(nil)
ts := uint32(time.Now().Unix())
tsb := []byte{byte(ts), byte(ts >> 8), byte(ts >> 16), byte(ts >> 24)}
out := make([]byte, 32)
copy(out[:28], digest[:28])
for i := 0; i < 4; i++ {
out[28+i] = digest[28+i] ^ tsb[i]
}
return out
}
+32
View File
@@ -0,0 +1,32 @@
package faketls
import (
"bytes"
"encoding/hex"
"testing"
)
func TestBuildClientHello_Size(t *testing.T) {
key, err := hex.DecodeString("5ba9cf5cf84698032e5300c864544dd0")
if err != nil || len(key) != 16 {
t.Fatalf("key: %v len %d", err, len(key))
}
domain, err := hex.DecodeString("632e6170692e73746570612e6f6e65")
if err != nil {
t.Fatal(err)
}
ch, err := BuildClientHello(key, domain)
if err != nil {
t.Fatal(err)
}
if len(ch.Record) != 517 {
t.Fatalf("record len %d", len(ch.Record))
}
if !bytes.HasPrefix(ch.Record, []byte{0x16, 0x03, 0x01}) {
t.Fatal("bad record header")
}
pl := int(ch.Record[3])<<8 | int(ch.Record[4])
if pl != 512 {
t.Fatalf("record payload len %d", pl)
}
}
+471
View File
@@ -0,0 +1,471 @@
// Генерация ClientHello по логике Telegram Desktop (mtproto_tls_socket.cpp, PrepareClientHelloRules).
// См. https://github.com/telegramdesktop/tdesktop/blob/dev/Telegram/SourceFiles/mtproto/details/mtproto_tls_socket.cpp
package faketls
import (
"crypto/ed25519"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/binary"
"fmt"
"math/big"
"time"
)
const (
kMaxGrease = 8
kClientHelloLimit = 2048
kHelloDigestLen = 32
kLengthSize = 2
kElementsM = 384
kAddedM = 32
)
// BuildTdesktopClientHello строит запись TLS 1.2 ClientHello как в tdesktop (Ed25519, permutation, ECH padding, HMAC).
func BuildTdesktopClientHello(secretKey []byte, domain []byte) (*ClientHello, error) {
if len(secretKey) != 16 {
return nil, fmt.Errorf("secret key must be 16 bytes")
}
greases := prepareGreases()
p := newTdesktopPart(domain, greases)
writeClientHelloRules(p)
if p.err {
return nil, fmt.Errorf("tdesktop client hello: build failed")
}
p.finalize(secretKey)
if p.err {
return nil, fmt.Errorf("tdesktop client hello: finalize failed")
}
digest := p.extractDigest()
out := p.take()
if len(out) == 0 {
return nil, fmt.Errorf("tdesktop client hello: empty buffer")
}
return &ClientHello{
Record: out,
RandomField: append([]byte(nil), digest...),
SessionID: p.sessionID(),
}, nil
}
func prepareGreases() []byte {
result := make([]byte, kMaxGrease)
if _, err := rand.Read(result); err != nil {
return bytesRepeat(0x0a, kMaxGrease)
}
for i := range result {
result[i] = (result[i] & 0xf0) + 0x0a
}
for i := 0; i < kMaxGrease; i += 2 {
if result[i] == result[i+1] {
result[i+1] ^= 0x10
}
}
return result
}
func bytesRepeat(b byte, n int) []byte {
out := make([]byte, n)
for i := range out {
out[i] = b
}
return out
}
type tdesktopPart struct {
domain []byte
greases []byte
buf []byte
digestPosition int
err bool
}
func newTdesktopPart(domain []byte, greases []byte) *tdesktopPart {
return &tdesktopPart{
domain: domain,
greases: greases,
digestPosition: -1,
}
}
func (p *tdesktopPart) grow(n int) []byte {
if p.err || n <= 0 || len(p.buf)+n > kClientHelloLimit {
p.err = true
return nil
}
off := len(p.buf)
p.buf = append(p.buf, make([]byte, n)...)
return p.buf[off : off+n]
}
func (p *tdesktopPart) writeString(b []byte) {
st := p.grow(len(b))
if st == nil {
return
}
copy(st, b)
}
func (p *tdesktopPart) writeZeros(n int) {
already := len(p.buf)
st := p.grow(n)
if st == nil {
return
}
if n == kHelloDigestLen && p.digestPosition < 0 {
p.digestPosition = already
}
clear(st)
}
func (p *tdesktopPart) writeGrease(seed int) {
if seed < 0 || seed >= len(p.greases) {
p.err = true
return
}
st := p.grow(2)
if st == nil {
return
}
// В tdesktop: bytes::set_with_const(storage, _greases[seed]) — оба байта равны значению seed.
st[0] = p.greases[seed]
st[1] = p.greases[seed]
}
func (p *tdesktopPart) writeRandom(n int) {
st := p.grow(n)
if st == nil {
return
}
if _, err := rand.Read(st); err != nil {
p.err = true
}
}
func (p *tdesktopPart) writeDomain() {
st := p.grow(len(p.domain))
if st == nil {
return
}
copy(st, p.domain)
}
func (p *tdesktopPart) writeEd25519PublicKey() {
pub, _, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
p.err = true
return
}
st := p.grow(32)
if st == nil {
return
}
copy(st, pub)
}
func (p *tdesktopPart) writeScope(f func()) {
st := p.grow(kLengthSize)
if st == nil {
return
}
already := len(p.buf)
f()
length := len(p.buf) - already
if length > 65535 {
p.err = true
return
}
binary.BigEndian.PutUint16(p.buf[already-2:already], uint16(length))
}
func (p *tdesktopPart) newSubPart() *tdesktopPart {
return newTdesktopPart(p.domain, p.greases)
}
func (p *tdesktopPart) writePermutation(elements []func(*tdesktopPart)) {
var parts [][]byte
for _, el := range elements {
sub := p.newSubPart()
el(sub)
if sub.err {
p.err = true
return
}
parts = append(parts, sub.take())
}
shuffleByteSlices(parts)
for _, b := range parts {
if p.err {
return
}
st := p.grow(len(b))
if st == nil {
return
}
copy(st, b)
}
}
func shuffleByteSlices(s [][]byte) {
n := len(s)
for i := n - 1; i > 0; i-- {
jBig, err := rand.Int(rand.Reader, big.NewInt(int64(i+1)))
j := 0
if err != nil {
j = i
} else {
j = int(jBig.Int64())
}
s[i], s[j] = s[j], s[i]
}
}
func (p *tdesktopPart) writeM() {
kElements := kElementsM
kAdded := kAddedM
storage := p.grow(kElements*3 + kAdded)
if storage == nil {
return
}
random := make([]byte, kElements*8+kAdded)
if _, err := rand.Read(random); err != nil {
p.err = true
return
}
chars := storage
ints := make([]uint32, len(random)/4)
for i := 0; i < len(ints); i++ {
ints[i] = binary.LittleEndian.Uint32(random[i*4 : i*4+4])
}
ci := 0
for i := 0; i < kElements; i++ {
a := int(ints[i*2] % 3329)
b := int(ints[i*2+1] % 3329)
chars[ci] = byte(a & 255)
chars[ci+1] = byte((a >> 8) + ((b & 15) << 4))
chars[ci+2] = byte(b >> 4)
ci += 3
}
tail := storage[kElements*3:]
if _, err := rand.Read(tail); err != nil {
p.err = true
}
}
func (p *tdesktopPart) writeE() {
lengths := []int{144, 176, 208, 240}
var pick [1]byte
if _, err := rand.Read(pick[:]); err != nil {
p.err = true
return
}
length := lengths[int(pick[0])%len(lengths)]
p.writeRandom(length)
}
func (p *tdesktopPart) writePadding() {
if p.err {
return
}
cur := len(p.buf)
if cur >= 513 {
return
}
z := 513 - cur
p.writeString([]byte{0x00, 0x15})
p.writeScope(func() {
p.writeZeros(z)
})
}
func (p *tdesktopPart) finalize(key []byte) {
if p.err || p.digestPosition < 0 {
p.err = true
return
}
mac := hmac.New(sha256.New, key)
mac.Write(p.buf)
sum := mac.Sum(nil)
copy(p.buf[p.digestPosition:p.digestPosition+kHelloDigestLen], sum)
injectTimestamp(p.buf[p.digestPosition : p.digestPosition+kHelloDigestLen])
}
func injectTimestamp(digest32 []byte) {
if len(digest32) != 32 {
return
}
st := digest32[28:32]
u := binary.LittleEndian.Uint32(st)
ts := uint32(time.Now().Unix())
u ^= ts
binary.LittleEndian.PutUint32(st, u)
}
func (p *tdesktopPart) extractDigest() []byte {
if p.digestPosition < 0 {
return nil
}
return append([]byte(nil), p.buf[p.digestPosition:p.digestPosition+kHelloDigestLen]...)
}
func (p *tdesktopPart) take() []byte {
if p.err {
return nil
}
return append([]byte(nil), p.buf...)
}
// sessionID: первый 32-байтный Random сразу после префикса 0x20 (session id в ClientHello).
func (p *tdesktopPart) sessionID() []byte {
if p.err {
return nil
}
return firstR32Payload(p.buf)
}
func firstR32Payload(buf []byte) []byte {
pat := []byte{0x03, 0x03}
i := bytesIndex(buf, pat)
if i < 0 {
return nil
}
j := i + len(pat) + 32 // после Z(32) digest
if j+1+32 > len(buf) {
return nil
}
if buf[j] != 0x20 {
return nil
}
return append([]byte(nil), buf[j+1:j+1+32]...)
}
func bytesIndex(hay []byte, needle []byte) int {
outer:
for i := 0; i+len(needle) <= len(hay); i++ {
for k := range needle {
if hay[i+k] != needle[k] {
continue outer
}
}
return i
}
return -1
}
var cipherList32 = []byte{
0x13, 0x01, 0x13, 0x02, 0x13, 0x03, 0xc0, 0x2b, 0xc0, 0x2f, 0xc0, 0x2c, 0xc0, 0x30,
0xcc, 0xa9, 0xcc, 0xa8, 0xc0, 0x13, 0xc0, 0x14, 0x00, 0x9c, 0x00, 0x9d, 0x00, 0x2f, 0x00, 0x35,
0x01, 0x00,
}
func writeClientHelloRules(p *tdesktopPart) {
p.writeString([]byte{0x16, 0x03, 0x01})
p.writeScope(func() {
p.writeString([]byte{0x01, 0x00})
p.writeScope(func() {
p.writeString([]byte{0x03, 0x03})
p.writeZeros(32)
p.writeString([]byte{0x20})
p.writeRandom(32)
p.writeString([]byte{0x00, 0x20})
p.writeGrease(0)
p.writeString(cipherList32)
p.writeScope(func() {
p.writeGrease(2)
p.writeString([]byte{0x00, 0x00})
p.writePermutation(tdesktopPermutationElements())
p.writeGrease(3)
p.writeString([]byte{0x00, 0x01, 0x00})
p.writePadding()
})
})
})
}
func tdesktopPermutationElements() []func(*tdesktopPart) {
return []func(*tdesktopPart){
func(p *tdesktopPart) {
p.writeString([]byte{0x00, 0x00})
p.writeScope(func() {
p.writeScope(func() {
p.writeString([]byte{0x00})
p.writeScope(func() {
p.writeDomain()
})
})
})
},
func(p *tdesktopPart) {
p.writeString([]byte{0x00, 0x05, 0x00, 0x05, 0x01, 0x00, 0x00, 0x00, 0x00})
},
func(p *tdesktopPart) {
p.writeString([]byte{0x00, 0x0a, 0x00, 0x0c, 0x00, 0x0a})
p.writeGrease(4)
p.writeString([]byte{0x11, 0xec, 0x00, 0x1d, 0x00, 0x17, 0x00, 0x18})
},
func(p *tdesktopPart) {
p.writeString([]byte{0x00, 0x0b, 0x00, 0x02, 0x01, 0x00})
},
func(p *tdesktopPart) {
p.writeString([]byte{
0x00, 0x0d, 0x00, 0x12, 0x00, 0x10, 0x04, 0x03, 0x08, 0x04, 0x04, 0x01, 0x05, 0x03,
0x08, 0x05, 0x05, 0x01, 0x08, 0x06, 0x06, 0x01,
})
},
func(p *tdesktopPart) {
p.writeString([]byte{
0x00, 0x10, 0x00, 0x0e, 0x00, 0x0c, 0x02, 0x68, 0x32, 0x08, 0x68, 0x74, 0x74, 0x70,
0x2f, 0x31, 0x2e, 0x31,
})
},
func(p *tdesktopPart) {
p.writeString([]byte{0x00, 0x12, 0x00, 0x00})
},
func(p *tdesktopPart) {
p.writeString([]byte{0x00, 0x17, 0x00, 0x00})
},
func(p *tdesktopPart) {
p.writeString([]byte{0x00, 0x1b, 0x00, 0x03, 0x02, 0x00, 0x02})
},
func(p *tdesktopPart) {
p.writeString([]byte{0x00, 0x23, 0x00, 0x00})
},
func(p *tdesktopPart) {
p.writeString([]byte{0x00, 0x2b, 0x00, 0x07, 0x06})
p.writeGrease(6)
p.writeString([]byte{0x03, 0x04, 0x03, 0x03})
},
func(p *tdesktopPart) {
p.writeString([]byte{0x00, 0x2d, 0x00, 0x02, 0x01, 0x01})
},
func(p *tdesktopPart) {
p.writeString([]byte{0x00, 0x33, 0x04, 0xef, 0x04, 0xed})
p.writeGrease(4)
p.writeString([]byte{0x00, 0x01, 0x00, 0x11, 0xec, 0x04, 0xc0})
p.writeM()
p.writeEd25519PublicKey()
p.writeString([]byte{0x00, 0x1d, 0x00, 0x20})
p.writeEd25519PublicKey()
},
func(p *tdesktopPart) {
p.writeString([]byte{0x44, 0xcd, 0x00, 0x05, 0x00, 0x03, 0x02, 0x68, 0x32})
},
func(p *tdesktopPart) {
p.writeString([]byte{0xfe, 0x0d})
p.writeScope(func() {
p.writeString([]byte{0x00, 0x00, 0x01, 0x00, 0x01})
p.writeRandom(1)
p.writeString([]byte{0x00, 0x20})
p.writeRandom(32)
p.writeScope(func() {
p.writeE()
})
})
},
func(p *tdesktopPart) {
p.writeString([]byte{0xff, 0x01, 0x00, 0x01, 0x00})
},
}
}
+34
View File
@@ -0,0 +1,34 @@
package faketls
import (
"bytes"
"encoding/hex"
"testing"
)
func TestBuildTdesktopClientHello_shape(t *testing.T) {
key, err := hex.DecodeString("5ba9cf5cf84698032e5300c864544dd0")
if err != nil || len(key) != 16 {
t.Fatalf("key: %v len %d", err, len(key))
}
domain, err := hex.DecodeString("632e6170692e73746570612e6f6e65")
if err != nil {
t.Fatal(err)
}
ch, err := BuildTdesktopClientHello(key, domain)
if err != nil {
t.Fatal(err)
}
if len(ch.Record) < 5 || len(ch.Record) > kClientHelloLimit {
t.Fatalf("record len %d", len(ch.Record))
}
if !bytes.HasPrefix(ch.Record, []byte{0x16, 0x03, 0x01}) {
t.Fatal("bad record header")
}
if len(ch.RandomField) != 32 {
t.Fatalf("random/digest field len %d", len(ch.RandomField))
}
if len(ch.SessionID) != 32 {
t.Fatalf("session id len %d", len(ch.SessionID))
}
}
+25
View File
@@ -0,0 +1,25 @@
package faketls
import (
"io"
)
// WriteTLSApplicationData wraps payload in TLS 1.2 application records (type 0x17).
func WriteTLSApplicationData(w io.Writer, p []byte) error {
const maxChunk = 16384
for off := 0; off < len(p); off += maxChunk {
end := off + maxChunk
if end > len(p) {
end = len(p)
}
chunk := p[off:end]
hdr := []byte{0x17, 0x03, 0x03, byte(len(chunk) >> 8), byte(len(chunk))}
if _, err := w.Write(hdr); err != nil {
return err
}
if _, err := w.Write(chunk); err != nil {
return err
}
}
return nil
}
+115
View File
@@ -0,0 +1,115 @@
package faketls
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/binary"
"fmt"
"io"
)
// Маркеры и разбор длин — как в mtproto_tls_socket.cpp (checkHelloParts12 / checkHelloParts34),
// а не фиксированные 127+6+3+2 байта telethon + «http»-хвост (на части хостов это давало ложную длину хвоста).
var (
kServerHelloPart1 = []byte{0x16, 0x03, 0x03}
kServerHelloPart3 = []byte{0x14, 0x03, 0x03, 0x00, 0x01, 0x01, 0x17, 0x03, 0x03}
)
const kTLSLengthSize = 2
// ReadServerHello читает цепочку fake-TLS ServerHello до конца части, по которой считается digest (как в tdesktop).
func ReadServerHello(r io.Reader) ([]byte, error) {
parts1Size := len(kServerHelloPart1) + kTLSLengthSize
header := make([]byte, parts1Size)
if _, err := io.ReadFull(r, header); err != nil {
return nil, fmt.Errorf("read tls record header: %w", err)
}
part1Off := parts1Size - kTLSLengthSize - len(kServerHelloPart1)
if !bytes.Equal(header[part1Off:part1Off+len(kServerHelloPart1)], kServerHelloPart1) {
return nil, fmt.Errorf("server hello: bad tls prefix")
}
part2Size := int(binary.BigEndian.Uint16(header[parts1Size-kTLSLengthSize:]))
if part2Size <= 0 || part2Size > 1<<20 {
return nil, fmt.Errorf("invalid tls fragment length %d", part2Size)
}
midLen := part2Size + len(kServerHelloPart3) + kTLSLengthSize
mid := make([]byte, midLen)
if _, err := io.ReadFull(r, mid); err != nil {
return nil, fmt.Errorf("read server hello body: %w", err)
}
part3Off := part2Size
if !bytes.Equal(mid[part3Off:part3Off+len(kServerHelloPart3)], kServerHelloPart3) {
return nil, fmt.Errorf("server hello: unexpected middle tls markers")
}
part4Size := int(binary.BigEndian.Uint16(mid[len(mid)-kTLSLengthSize:]))
if part4Size < 0 || part4Size > 1<<20 {
return nil, fmt.Errorf("invalid final fragment length %d", part4Size)
}
tail := make([]byte, part4Size)
if _, err := io.ReadFull(r, tail); err != nil {
return nil, fmt.Errorf("read server hello final fragment: %w", err)
}
out := append(append(append([]byte{}, header...), mid...), tail...)
return out, nil
}
// VerifyServerHelloFull validates the proxy response (telethon-faketls rules).
func VerifyServerHelloFull(full []byte, secretKey []byte, clientRandom []byte, sessionID []byte) error {
if len(full) < 127+6+3+2 {
return fmt.Errorf("server hello too short")
}
if !bytes.HasPrefix(full, []byte{0x16, 0x03, 0x03}) {
return fmt.Errorf("server hello: bad tls prefix")
}
marker := []byte{0x14, 0x03, 0x03, 0x00, 0x01, 0x01, 0x17, 0x03, 0x03}
if len(full) < 127+len(marker) || !bytes.Equal(full[127:127+len(marker)], marker) {
return fmt.Errorf("server hello: unexpected middle tls markers")
}
sidStart := 11 + 32 + 1
if len(full) < sidStart+32 || !bytes.Equal(full[sidStart:sidStart+32], sessionID) {
return fmt.Errorf("server hello: session id mismatch")
}
patched := make([]byte, len(full))
copy(patched, full)
clear(patched[11:43])
mac := hmac.New(sha256.New, secretKey)
mac.Write(clientRandom)
mac.Write(patched)
want := mac.Sum(nil)
if !bytes.Equal(full[11:43], want) {
return fmt.Errorf("server hello: digest mismatch")
}
return nil
}
const (
kServerHelloDigestPosition = 11
)
// VerifyServerHelloTdesktop проверяет ответ прокси по правилам mtproto_tls_socket.cpp (checkHelloDigest):
// HMAC-SHA256(key, client_digest||server_bytes) при обнулённом digest сервера в буфере.
func VerifyServerHelloTdesktop(serverResponse []byte, secretKey []byte, clientDigest []byte) error {
if len(secretKey) != 16 {
return fmt.Errorf("secret key must be 16 bytes")
}
if len(clientDigest) != 32 {
return fmt.Errorf("client digest must be 32 bytes")
}
if len(serverResponse) < kServerHelloDigestPosition+32 {
return fmt.Errorf("server hello too short for digest")
}
fulldata := make([]byte, 0, len(clientDigest)+len(serverResponse))
fulldata = append(fulldata, clientDigest...)
fulldata = append(fulldata, serverResponse...)
digestOff := len(clientDigest) + kServerHelloDigestPosition
digestCopy := append([]byte(nil), fulldata[digestOff:digestOff+32]...)
clear(fulldata[digestOff : digestOff+32])
mac := hmac.New(sha256.New, secretKey)
mac.Write(fulldata)
want := mac.Sum(nil)
if !bytes.Equal(digestCopy, want) {
return fmt.Errorf("server hello: digest mismatch (tdesktop)")
}
return nil
}
+27
View File
@@ -0,0 +1,27 @@
package faketls
import (
crand "crypto/rand"
"golang.org/x/crypto/curve25519"
)
// GenX25519PublicKey returns a valid X25519 public key (32 bytes) for the ClientHello key_share extension.
// Some MTProxy builds validate the point; a random quadratic residue mod P (older telethon-faketls trick) is rejected.
func GenX25519PublicKey() ([]byte, error) {
var priv, pub [32]byte
if _, err := crand.Read(priv[:]); err != nil {
return nil, err
}
curve25519.ScalarBaseMult(&pub, &priv)
return pub[:], nil
}
// Rand32 returns 32 random bytes (session id).
func Rand32() ([]byte, error) {
b := make([]byte, 32)
if _, err := crand.Read(b); err != nil {
return nil, err
}
return b, nil
}
+103
View File
@@ -0,0 +1,103 @@
package mtproxy
import (
"bytes"
"crypto/aes"
"crypto/cipher"
crand "crypto/rand"
"crypto/sha256"
"encoding/binary"
"fmt"
"io"
)
// ObfuscateTagRandomizedIntermediate matches Telethon RandomizedIntermediatePacketCodec.
var ObfuscateTagRandomizedIntermediate = []byte{0xdd, 0xdd, 0xdd, 0xdd}
// randSource is injectable for tests.
var randSource io.Reader = crand.Reader
// InitHeader returns the 64-byte first payload (MTProxyIO.init_header) and CTR streams.
func InitHeader(secret16 []byte, dcID int16) (header64 []byte, enc cipher.Stream, dec cipher.Stream, err error) {
if len(secret16) != 16 {
return nil, nil, nil, fmt.Errorf("mtproxy secret must be 16 bytes")
}
sec := secret16
keywords := [][]byte{
[]byte("PVrG"),
[]byte("GET "),
[]byte("POST"),
{0xee, 0xee, 0xee, 0xee},
}
var rnd [64]byte
for {
if _, err := io.ReadFull(randSource, rnd[:]); err != nil {
return nil, nil, nil, err
}
if rnd[0] == 0xef {
continue
}
bad := false
for _, k := range keywords {
if len(k) <= 4 && bytes.Equal(rnd[:len(k)], k) {
bad = true
break
}
}
if bad {
continue
}
if rnd[4] == 0 && rnd[5] == 0 && rnd[6] == 0 && rnd[7] == 0 {
continue
}
break
}
random := rnd[:]
rev := make([]byte, 48)
for i := 0; i < 48; i++ {
rev[i] = random[55-i]
}
encKey := sha256Sum(append(append([]byte{}, random[8:40]...), sec...))
encIV := random[40:56]
decKey := sha256Sum(append(append([]byte{}, rev[:32]...), sec...))
decIV := rev[32:48]
enc, err = newCTR(encKey, encIV)
if err != nil {
return nil, nil, nil, err
}
dec, err = newCTR(decKey, decIV)
if err != nil {
return nil, nil, nil, err
}
buf := make([]byte, 64)
copy(buf, random)
copy(buf[56:60], ObfuscateTagRandomizedIntermediate)
dcBytes := make([]byte, 2)
binary.LittleEndian.PutUint16(dcBytes, uint16(dcID))
copy(buf[60:62], dcBytes)
encChunk := make([]byte, 8)
copy(encChunk, buf[56:64])
enc.XORKeyStream(encChunk, encChunk)
copy(buf[56:64], encChunk)
return buf, enc, dec, nil
}
func sha256Sum(b []byte) []byte {
h := sha256.Sum256(b)
return h[:]
}
func newCTR(key, iv []byte) (cipher.Stream, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
return cipher.NewCTR(block, iv), nil
}
+21
View File
@@ -0,0 +1,21 @@
package mtproxy
import (
"testing"
)
func TestInitHeader_Length(t *testing.T) {
key := make([]byte, 16)
for i := range key {
key[i] = byte(i + 1)
}
hdr, enc, dec, err := InitHeader(key, 2)
if err != nil {
t.Fatal(err)
}
if len(hdr) != 64 {
t.Fatalf("header len %d", len(hdr))
}
_ = enc
_ = dec
}
+57
View File
@@ -0,0 +1,57 @@
package parseurl
import (
"fmt"
"net/url"
"strconv"
"strings"
)
// Target is host, port, secret string from tg://proxy or explicit flags.
type Target struct {
Host string
Port int
Secret string
}
// ParseTGProxy parses tg://proxy?server=&port=&secret= or t.me/proxy style query.
func ParseTGProxy(raw string) (*Target, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, fmt.Errorf("empty proxy url")
}
u, err := url.Parse(raw)
if err != nil {
return nil, err
}
if u.Scheme != "tg" {
return nil, fmt.Errorf("expected tg:// scheme, got %q", u.Scheme)
}
host := u.Hostname()
if host != "" && host != "proxy" {
return nil, fmt.Errorf("unexpected tg host %q", host)
}
q := u.Query()
server := strings.TrimSpace(q.Get("server"))
if server == "" {
return nil, fmt.Errorf("missing server parameter")
}
portStr := strings.TrimSpace(q.Get("port"))
if portStr == "" {
return nil, fmt.Errorf("missing port parameter")
}
port, err := strconv.Atoi(portStr)
if err != nil || port < 1 || port > 65535 {
return nil, fmt.Errorf("invalid port %q", portStr)
}
sec := strings.TrimSpace(q.Get("secret"))
if sec == "" {
return nil, fmt.Errorf("missing secret parameter")
}
return &Target{Host: server, Port: port, Secret: sec}, nil
}
+17
View File
@@ -0,0 +1,17 @@
package parseurl
import "testing"
func TestParseTGProxy(t *testing.T) {
raw := "tg://proxy?server=example.com&port=443&secret=ee0102030405060708090a0b0c0d0e0f10632e6578616d706c652e636f6d"
tg, err := ParseTGProxy(raw)
if err != nil {
t.Fatal(err)
}
if tg.Host != "example.com" || tg.Port != 443 {
t.Fatalf("%+v", tg)
}
if tg.Secret == "" {
t.Fatal("secret")
}
}
+111
View File
@@ -0,0 +1,111 @@
package secret
import (
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"strings"
)
type Kind int
const (
KindEE Kind = iota
KindDD
)
// Parsed holds normalized MTProxy secret material for handshake and MTProxyIO.
type Parsed struct {
Kind Kind
// Key is always 16 bytes (MTProxy user secret).
Key []byte
// Domain is raw bytes after the key in ee-secrets (SNI payload); empty for dd.
Domain []byte
// RawHex is the original secret string (lowercase hex) for logging.
RawHex string
}
var (
ErrInvalidSecret = errors.New("invalid mtproxy secret")
)
// Parse decodes Telegram MTProxy secret from tg:// links (hex or legacy base64).
func Parse(secretStr string) (*Parsed, error) {
s := strings.TrimSpace(strings.ToLower(secretStr))
if s == "" {
return nil, fmt.Errorf("%w: empty", ErrInvalidSecret)
}
// Strip common tg:// noise
s = strings.TrimPrefix(s, "0x")
var raw []byte
if isHex(s) {
b, err := hex.DecodeString(s)
if err != nil {
return nil, fmt.Errorf("%w: hex: %v", ErrInvalidSecret, err)
}
raw = b
} else {
// Telegram sometimes uses base64 secrets
pad := strings.Repeat("=", (4-len(s)%4)%4)
b, err := base64.StdEncoding.DecodeString(s + pad)
if err != nil {
b, err = base64.URLEncoding.DecodeString(s + pad)
}
if err != nil {
return nil, fmt.Errorf("%w: not hex or base64: %v", ErrInvalidSecret, err)
}
raw = b
}
if len(raw) == 0 {
return nil, fmt.Errorf("%w: no payload", ErrInvalidSecret)
}
switch raw[0] {
case 0xdd:
if len(raw) != 17 {
return nil, fmt.Errorf("%w: dd secret must be 17 bytes, got %d", ErrInvalidSecret, len(raw))
}
key := make([]byte, 16)
copy(key, raw[1:])
return &Parsed{
Kind: KindDD,
Key: key,
Domain: nil,
RawHex: s,
}, nil
case 0xee:
if len(raw) < 18 {
return nil, fmt.Errorf("%w: ee secret too short", ErrInvalidSecret)
}
key := make([]byte, 16)
copy(key, raw[1:17])
domain := make([]byte, len(raw)-17)
copy(domain, raw[17:])
return &Parsed{
Kind: KindEE,
Key: key,
Domain: domain,
RawHex: s,
}, nil
default:
return nil, fmt.Errorf("%w: unknown first byte 0x%02x (expected ee/dd)", ErrInvalidSecret, raw[0])
}
}
func isHex(s string) bool {
for _, r := range s {
switch {
case r >= '0' && r <= '9', r >= 'a' && r <= 'f':
default:
return false
}
}
return len(s)%2 == 0 && len(s) >= 2
}
+47
View File
@@ -0,0 +1,47 @@
package secret
import (
"bytes"
"encoding/hex"
"testing"
)
func TestParseEE_UserExample(t *testing.T) {
s := "ee5ba9cf5cf84698032e5300c864544dd0632e6170692e73746570612e6f6e65"
p, err := Parse(s)
if err != nil {
t.Fatal(err)
}
if p.Kind != KindEE {
t.Fatalf("kind %v", p.Kind)
}
raw, err := hex.DecodeString(s)
if err != nil {
t.Fatal(err)
}
if raw[0] != 0xee {
t.Fatal("marker")
}
if !bytes.Equal(p.Key, raw[1:17]) {
t.Fatalf("key: got %x want %x", p.Key, raw[1:17])
}
if !bytes.Equal(p.Domain, raw[17:]) {
t.Fatalf("domain: got %x want %x", p.Domain, raw[17:])
}
}
func TestParseDD(t *testing.T) {
s := "dd00000000000000000000000000000000"
p, err := Parse(s)
if err != nil {
t.Fatal(err)
}
if p.Kind != KindDD {
t.Fatalf("kind %v", p.Kind)
}
for _, b := range p.Key {
if b != 0 {
t.Fatal("expected zero key")
}
}
}