commit 4905c06b9b9ce17467255d4bddec50b6afcf1ecf Author: Denozordec Date: Sat Apr 11 00:38:22 2026 +0700 init diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b1fc57c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +.git +.gitignore +.cursor +*.exe +mtproxy_checker +README.md +docs +.gitea diff --git a/.gitea/workflows/docker.yaml b/.gitea/workflows/docker.yaml new file mode 100644 index 0000000..f6edc0b --- /dev/null +++ b/.gitea/workflows/docker.yaml @@ -0,0 +1,100 @@ +name: Publish mtproxy_checker Docker image + +on: + push: + branches: + - "**" + tags: + - "*" + workflow_dispatch: + +env: + GO_VERSION: "1.22" + REGISTRY: git.shts.su + IMAGE_REPO: ${{ gitea.repository }} + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: ${{ env.GO_VERSION }} + + - name: Test + run: go mod tidy && go test ./... + + build-and-push: + runs-on: ubuntu-latest + needs: test + timeout-minutes: 20 + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + with: + driver-opts: | + image=moby/buildkit:v0.13.2 + + - name: Log in to Gitea Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ gitea.actor }} + password: ${{ secrets.ACTIONS_PAT }} + + - name: Prepare Docker tags + id: prep + shell: bash + env: + REPO: ${{ gitea.repository }} + SHA: ${{ gitea.sha }} + REF_NAME: ${{ gitea.ref_name }} + run: | + set -euo pipefail + IMAGE="${REGISTRY}/${REPO}" + BRANCH_OR_TAG="${REF_NAME:-unknown}" + SAFE_REF="$(echo "${BRANCH_OR_TAG}" | tr '/' '-')" + SHORT_SHA="$(echo "${SHA}" | cut -c1-12)" + + TAGS="$(printf '%s\n%s\n%s\n' \ + "${IMAGE}:latest" \ + "${IMAGE}:${SAFE_REF}" \ + "${IMAGE}:sha-${SHORT_SHA}")" + + echo "tags<> "${GITHUB_OUTPUT}" + echo "${TAGS}" >> "${GITHUB_OUTPUT}" + echo "EOF" >> "${GITHUB_OUTPUT}" + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + file: ./Dockerfile + push: true + platforms: linux/amd64 + cache-from: | + type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_REPO }}:latest + cache-to: type=inline + tags: ${{ steps.prep.outputs.tags }} + labels: | + org.opencontainers.image.title=mtproxy_checker + org.opencontainers.image.description=CLI probe for MTProxy (ee Fake-TLS + dd), Telegram-style ClientHello + org.opencontainers.image.revision=${{ gitea.sha }} + org.opencontainers.image.source=${{ gitea.server_url }}/${{ gitea.repository }} + provenance: false diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4e623c0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/mtproxy_checker +/mtproxy_checker.exe +*.exe +.idea/ +.vscode/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4fa44f9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +# Build stage +FROM golang:1.22-alpine AS build +WORKDIR /src +RUN apk add --no-cache ca-certificates git +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /mtproxy_checker ./cmd/mtproxy_checker + +# Runtime +FROM alpine:3.20 +RUN apk add --no-cache ca-certificates +COPY --from=build /mtproxy_checker /usr/local/bin/mtproxy_checker +ENTRYPOINT ["/usr/local/bin/mtproxy_checker"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..ffbbab3 --- /dev/null +++ b/README.md @@ -0,0 +1,31 @@ +# mtproxy_checker + +Проверка **MTProto MTProxy** на Go: не ICMP ping, а TCP + протокол (для `ee` — Fake-TLS, затем первый кадр MTProxy; для `dd` — сразу MTProxy Randomized Intermediate). + +## Сборка + +```powershell +go build -o mtproxy_checker.exe ./cmd/mtproxy_checker +``` + +## Запуск + +```powershell +.\mtproxy_checker.exe "tg://proxy?server=HOST&port=PORT&secret=HEX" +``` + +или + +```powershell +.\mtproxy_checker.exe --server HOST --port PORT --secret HEX +``` + +Флаги: `-timeout` (по умолчанию 15s), `-dc-id` (по умолчанию 2). + +Код выхода: `0` — OK, `1` — ошибка, `2` — неверные аргументы, `3` — прокси закрыл соединение после проверки, `4` — таймаут. + +**Docker (описание образа, Linux, несколько прокси подряд):** [docs/docker.ru.md](docs/docker.ru.md). + +### Если в Telegram прокси «Available», а утилита падает на `read server hello` + +Для `ee` в TLS SNI должен идти **только ASCII hostname** из хвоста секрета; в коде это `SNIDomain`. Ответ сервера читается по схеме из Telegram Desktop (`mtproto_tls_socket`), а не по старому фиксированному формату telethon. diff --git a/cmd/mtproxy_checker/main.go b/cmd/mtproxy_checker/main.go new file mode 100644 index 0000000..9532cf9 --- /dev/null +++ b/cmd/mtproxy_checker/main.go @@ -0,0 +1,83 @@ +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "os" + "time" + + "mtproxy_checker/internal/checker" + "mtproxy_checker/internal/parseurl" + "mtproxy_checker/internal/secret" +) + +func main() { + os.Exit(run()) +} + +func run() int { + timeout := flag.Duration("timeout", 15*time.Second, "overall TCP/handshake timeout") + dcID := flag.Int("dc-id", 2, "Telegram DC id (signed int16) embedded in MTProxy header") + server := flag.String("server", "", "proxy hostname (if not using tg:// positional)") + portFlag := flag.Int("port", 0, "proxy port (if not using tg:// positional)") + secFlag := flag.String("secret", "", "hex secret (if not using tg:// positional)") + flag.Parse() + + args := flag.Args() + var host string + var port int + var secStr string + + if len(args) >= 1 && (hasPrefix(args[0], "tg://") || hasPrefix(args[0], "TG://")) { + t, err := parseurl.ParseTGProxy(args[0]) + if err != nil { + fmt.Fprintf(os.Stderr, "parse tg url: %v\n", err) + return 2 + } + host, port, secStr = t.Host, t.Port, t.Secret + } else { + if *server == "" || *portFlag == 0 || *secFlag == "" { + fmt.Fprintf(os.Stderr, "usage: %s [flags] tg://proxy?server=HOST&port=PORT&secret=HEX\n", os.Args[0]) + fmt.Fprintf(os.Stderr, " or: %s --server HOST --port PORT --secret HEX\n", os.Args[0]) + flag.PrintDefaults() + return 2 + } + host, port, secStr = *server, *portFlag, *secFlag + } + + parsed, err := secret.Parse(secStr) + if err != nil { + fmt.Fprintf(os.Stderr, "secret: %v\n", err) + return 2 + } + + if *dcID < -32768 || *dcID > 32767 { + fmt.Fprintf(os.Stderr, "dc-id out of int16 range\n") + return 2 + } + + ctx, cancel := context.WithTimeout(context.Background(), *timeout) + defer cancel() + + err = checker.Check(ctx, host, port, parsed, int16(*dcID)) + if err != nil { + if errors.Is(err, checker.ErrProxyClosed) { + fmt.Fprintf(os.Stderr, "FAIL: %v\n", err) + return 3 + } + if errors.Is(err, context.DeadlineExceeded) { + fmt.Fprintf(os.Stderr, "FAIL: timeout\n") + return 4 + } + fmt.Fprintf(os.Stderr, "FAIL: %v\n", err) + return 1 + } + fmt.Println("OK") + return 0 +} + +func hasPrefix(s, p string) bool { + return len(s) >= len(p) && s[:len(p)] == p +} diff --git a/docs/docker.ru.md b/docs/docker.ru.md new file mode 100644 index 0000000..3d24fad --- /dev/null +++ b/docs/docker.ru.md @@ -0,0 +1,243 @@ +# Docker: образ mtproxy_checker + +## Что внутри образа + +| Компонент | Описание | +|-----------|----------| +| **База runtime** | Alpine Linux 3.20 | +| **Бинарник** | Статическая сборка Go (`CGO_ENABLED=0`), путь в контейнере: `/usr/local/bin/mtproxy_checker` | +| **Сертификаты** | Пакет `ca-certificates` (для TLS к реальным хостам при необходимости) | +| **ENTRYPOINT** | Тот же бинарник — все аргументы `docker run …` передаются в утилиту | +| **Порты** | Ничего не слушает: только **исходящие** TCP до хоста и порта из ссылки | + +Одна команда `docker run` = **одна** проверка одного прокси; процесс завершается с **кодом выхода** (`0`…`4`), что удобно в CI и скриптах. + +## Требования + +- [Docker Engine](https://docs.docker.com/engine/install/) (Linux) или Docker Desktop (Windows/macOS). +- Для `docker pull` из приватного registry — логин и токен. + +## Сборка образа локально + +**Linux / macOS:** + +```bash +cd /path/to/mtproxy_checker +docker build -t mtproxy_checker:local . +``` + +**Windows (PowerShell):** + +```powershell +cd C:\path\to\mtproxy_checker +docker build -t mtproxy_checker:local . +``` + +Проверка, что образ собран: + +```bash +docker run --rm mtproxy_checker:local -timeout 5s 2>&1 | head -1 +# ожидается сообщение usage (код выхода 2 — нормально без URL) +``` + +## Запуск под Linux + +### Один прокси (ссылка `tg://`) + +```bash +docker run --rm mtproxy_checker:local \ + 'tg://proxy?server=example.com&port=443&secret=eeYOURHEX...' +``` + +С флагами утилиты (таймаут, DC): + +```bash +docker run --rm mtproxy_checker:local \ + -timeout 25s -dc-id 2 \ + 'tg://proxy?server=example.com&port=443&secret=eeYOURHEX...' +``` + +Чтобы не светить секрет в истории shell, прочитайте его из файла и передайте флагами (образ запускает бинарник как `ENTRYPOINT`, отдельный `sh` без смены entrypoint использовать нельзя): + +```bash +SECRET=$(tr -d ' \n' < secret.hex) +docker run --rm mtproxy_checker:local \ + --server example.com --port 443 --secret "$SECRET" +``` + +### Сеть и DNS на Linux + +- По умолчанию контейнер использует bridge Docker; нужен исходящий доступ к `server:port` и рабочий DNS. +- Если имена не резолвятся: + +```bash +docker run --rm --dns 8.8.8.8 mtproxy_checker:local 'tg://proxy?...' +``` + +- Режим сети хоста (как на машине без NAT; используйте осознанно): + +```bash +docker run --rm --network host mtproxy_checker:local 'tg://proxy?...' +``` + +На Docker Desktop для Windows/macOS `--network host` ведёт себя иначе, чем на Linux. + +## Несколько MTProxy ссылок подряд + +Утилита принимает **ровно один** прокси за запуск. Чтобы проверить много `tg://` ссылок, обходите их в оболочке. + +### Вариант 1: цикл в bash + +```bash +IMAGE=mtproxy_checker:local # или registry/owner/mtproxy_checker:latest + +for url in \ + 'tg://proxy?server=a.example.com&port=443&secret=ee1111...' \ + 'tg://proxy?server=b.example.com&port=8443&secret=ee2222...' \ + 'tg://proxy?server=c.example.com&port=443&secret=dd3333...' +do + echo "=== $url ===" + if docker run --rm "$IMAGE" -timeout 20s "$url"; then + echo "OK" + else + echo "FAIL (код $?)" + fi +done +``` + +### Вариант 2: файл — одна ссылка на строку + +`proxies.txt` (пустые строки и строки с `#` в начале можно пропускать): + +``` +tg://proxy?server=one.example.com&port=443&secret=ee... +tg://proxy?server=two.example.com&port=8443&secret=ee... +``` + +Скрипт: + +```bash +IMAGE=mtproxy_checker:local + +while IFS= read -r line || [ -n "$line" ]; do + line="${line#"${line%%[![:space:]]*}"}" # trim leading spaces + [ -z "$line" ] && continue + case "$line" in \#*) continue ;; esac + + echo "=== $line ===" + docker run --rm "$IMAGE" -timeout 20s "$line" && echo OK || echo "FAIL ($?)" +done < proxies.txt +``` + +### Вариант 3: параллельно (`xargs`) + +Осторожно: одновременно много контейнеров нагружают сеть и целевые прокси. + +```bash +IMAGE=mtproxy_checker:local + +grep -v '^\s*$\|^\s*#' proxies.txt | xargs -I{} -P 4 sh -c \ + 'echo "=== {}"; docker run --rm '"$IMAGE"' -timeout 20s "{}" || echo FAIL' +``` + +`-P 4` — не более четырёх проверок одновременно; уменьшите при необходимости. + +### Вариант 4: итоговый код для CI + +Если нужен «провал всего job при любом FAIL»: + +```bash +set -e +IMAGE=mtproxy_checker:local +while IFS= read -r url; do + [ -z "$url" ] && continue + case "$url" in \#*) continue ;; esac + docker run --rm "$IMAGE" -timeout 25s "$url" +done < proxies.txt +``` + +## Запуск из PowerShell (Windows) + +Одинарные кавычки с `tg://` в PowerShell неудобны; проще двойные: + +```powershell +docker run --rm mtproxy_checker:local ` + "tg://proxy?server=example.com&port=443&secret=eeYOURHEX..." +``` + +Несколько ссылок: + +```powershell +$urls = @( + "tg://proxy?server=a.com&port=443&secret=ee...", + "tg://proxy?server=b.com&port=8443&secret=ee..." +) +foreach ($u in $urls) { + Write-Host "=== $u ===" + docker run --rm mtproxy_checker:local -timeout 20s $u + if ($LASTEXITCODE -ne 0) { Write-Host "FAIL exit $LASTEXITCODE" } +} +``` + +## Загрузка образа из Container Registry Gitea + +1. Включите **Packages / Container Registry** на инстансе Gitea ([документация](https://docs.gitea.com/usage/packages/container)). +2. Создайте токен с правом на пакеты. +3. Логин: + +```bash +docker login git.shts.su +# или ваш registry +``` + +4. Сборка workflow публикует теги `latest`, имя ветки/тега и `sha-…` — см. `.gitea/workflows/docker.yaml`. + +```bash +docker pull git.shts.su/owner/mtproxy_checker:latest +docker run --rm git.shts.su/owner/mtproxy_checker:latest 'tg://proxy?...' +``` + +## Пример docker-compose + +Одна проверка за `compose run`: + +```yaml +services: + mtproxy-check: + image: mtproxy_checker:local + build: . + command: ['tg://proxy?server=example.com&port=443&secret=ee...'] +``` + +```bash +docker compose run --rm mtproxy-check +``` + +Для списка прокси удобнее внешний bash-цикл или отдельный `command: ["sh","-c","..."]` с циклом по файлу, смонтированному в volume. + +## Использование в CI другого проекта + +```bash +docker run --rm registry.example.com/owner/mtproxy_checker:latest \ + "tg://proxy?server=${MTPROXY_HOST}&port=${MTPROXY_PORT}&secret=${MTPROXY_SECRET}" +``` + +## Коды выхода и диагностика + +| Код | Значение | +|-----|----------| +| 0 | Проверка прошла | +| 1 | Ошибка (сеть, протокол, неверный ответ) | +| 2 | Неверные аргументы CLI | +| 3 | Прокси закрыл TCP сразу после начального payload | +| 4 | Общий таймаут (`-timeout`) | + +**Не путать с ping:** ICMP не используется. + +Типичные сообщения: + +- `connection refused` — порт закрыт или фильтр. +- `FAIL: timeout` — нет ответа за `-timeout`. +- Ошибки чтения/проверки ServerHello — несовместимый ответ или обрыв соединения. + +Для отладки без `--rm` можно посмотреть логи контейнера по id; с `--rm` контейнер удаляется сразу после выхода. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..907d731 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module mtproxy_checker + +go 1.22 + +require golang.org/x/crypto v0.31.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..9615005 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= diff --git a/internal/checker/checker.go b/internal/checker/checker.go new file mode 100644 index 0000000..c45fc12 --- /dev/null +++ b/internal/checker/checker.go @@ -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 +} diff --git a/internal/checker/dial.go b/internal/checker/dial.go new file mode 100644 index 0000000..d34a6df --- /dev/null +++ b/internal/checker/dial.go @@ -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 +} diff --git a/internal/faketls/domain.go b/internal/faketls/domain.go new file mode 100644 index 0000000..b59c8ee --- /dev/null +++ b/internal/faketls/domain.go @@ -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:] +} diff --git a/internal/faketls/domain_test.go b/internal/faketls/domain_test.go new file mode 100644 index 0000000..0d8dbb5 --- /dev/null +++ b/internal/faketls/domain_test.go @@ -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) + } +} diff --git a/internal/faketls/hello.go b/internal/faketls/hello.go new file mode 100644 index 0000000..eeff527 --- /dev/null +++ b/internal/faketls/hello.go @@ -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 +} diff --git a/internal/faketls/hello_test.go b/internal/faketls/hello_test.go new file mode 100644 index 0000000..c9ae36c --- /dev/null +++ b/internal/faketls/hello_test.go @@ -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) + } +} diff --git a/internal/faketls/tdesktop.go b/internal/faketls/tdesktop.go new file mode 100644 index 0000000..b092451 --- /dev/null +++ b/internal/faketls/tdesktop.go @@ -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}) + }, + } +} diff --git a/internal/faketls/tdesktop_test.go b/internal/faketls/tdesktop_test.go new file mode 100644 index 0000000..73af10d --- /dev/null +++ b/internal/faketls/tdesktop_test.go @@ -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)) + } +} diff --git a/internal/faketls/tlsapp.go b/internal/faketls/tlsapp.go new file mode 100644 index 0000000..a03ed87 --- /dev/null +++ b/internal/faketls/tlsapp.go @@ -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 +} diff --git a/internal/faketls/verify.go b/internal/faketls/verify.go new file mode 100644 index 0000000..d395950 --- /dev/null +++ b/internal/faketls/verify.go @@ -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 +} diff --git a/internal/faketls/x25519_fake.go b/internal/faketls/x25519_fake.go new file mode 100644 index 0000000..f0a3bfc --- /dev/null +++ b/internal/faketls/x25519_fake.go @@ -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 +} diff --git a/internal/mtproxy/header.go b/internal/mtproxy/header.go new file mode 100644 index 0000000..441714d --- /dev/null +++ b/internal/mtproxy/header.go @@ -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 +} diff --git a/internal/mtproxy/header_test.go b/internal/mtproxy/header_test.go new file mode 100644 index 0000000..690c90a --- /dev/null +++ b/internal/mtproxy/header_test.go @@ -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 +} diff --git a/internal/parseurl/parseurl.go b/internal/parseurl/parseurl.go new file mode 100644 index 0000000..867efcd --- /dev/null +++ b/internal/parseurl/parseurl.go @@ -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 +} diff --git a/internal/parseurl/parseurl_test.go b/internal/parseurl/parseurl_test.go new file mode 100644 index 0000000..ed1245c --- /dev/null +++ b/internal/parseurl/parseurl_test.go @@ -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") + } +} diff --git a/internal/secret/secret.go b/internal/secret/secret.go new file mode 100644 index 0000000..f678374 --- /dev/null +++ b/internal/secret/secret.go @@ -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 +} diff --git a/internal/secret/secret_test.go b/internal/secret/secret_test.go new file mode 100644 index 0000000..b4c3fe1 --- /dev/null +++ b/internal/secret/secret_test.go @@ -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") + } + } +}