From e54d9d60d3a44bcdc9ab19fd6bf78e83db51a0cb Mon Sep 17 00:00:00 2001 From: Alexey Dolotov Date: Mon, 30 Mar 2026 14:50:32 +0300 Subject: [PATCH 01/10] fix: stabilize flaky CI tests 1. Add sync.Mutex to ScoutConnCollected to eliminate data race between Add()/MarkWrite() in readLoop and learn() iterating results. Introduce Snapshot() for safe read access. 2. Increase bloom filter test size from 500 to 100000 to prevent false negatives from random eviction in the stable bloom filter. 3. Use Require().NoError() in TestHTTPSRequest to prevent nil-pointer panic on resp.Body.Close() when the request fails. Fixes #425 --- antireplay/stable_bloom_filter_test.go | 2 +- mtglib/internal/doppel/scout.go | 14 +++++++------ .../internal/doppel/scout_conn_collected.go | 21 ++++++++++++++++++- mtglib/proxy_test.go | 2 +- 4 files changed, 30 insertions(+), 9 deletions(-) diff --git a/antireplay/stable_bloom_filter_test.go b/antireplay/stable_bloom_filter_test.go index accb590..8c88ce2 100644 --- a/antireplay/stable_bloom_filter_test.go +++ b/antireplay/stable_bloom_filter_test.go @@ -12,7 +12,7 @@ type StableBloomFilterTestSuite struct { } func (suite *StableBloomFilterTestSuite) TestOp() { - filter := antireplay.NewStableBloomFilter(500, 0.001) + filter := antireplay.NewStableBloomFilter(100000, 0.001) suite.False(filter.SeenBefore([]byte{1, 2, 3})) suite.False(filter.SeenBefore([]byte{4, 5, 6})) diff --git a/mtglib/internal/doppel/scout.go b/mtglib/internal/doppel/scout.go index 4b58e32..d90feba 100644 --- a/mtglib/internal/doppel/scout.go +++ b/mtglib/internal/doppel/scout.go @@ -61,7 +61,9 @@ func (s Scout) learn(ctx context.Context, url string) (ScoutResult, error) { client.CloseIdleConnections() } - if err != nil || len(results.data) == 0 { + data, writeIndex := results.Snapshot() + + if err != nil || len(data) == 0 { return ScoutResult{}, err } @@ -70,14 +72,14 @@ func (s Scout) learn(ctx context.Context, url string) (ScoutResult, error) { // Compute inter-record durations (existing logic). lastTimestamp := time.Time{} - for i, v := range results.data { + for i, v := range data { if v.recordType != tls.TypeApplicationData { continue } if lastTimestamp.IsZero() { if i > 0 { - lastTimestamp = results.data[i-1].timestamp + lastTimestamp = data[i-1].timestamp } else { lastTimestamp = v.timestamp } @@ -90,12 +92,12 @@ func (s Scout) learn(ctx context.Context, url string) (ScoutResult, error) { // Compute cert size: sum of ApplicationData payload between CCS and // the first client Write (which marks the end of server handshake). seenCCS := false - boundary := results.writeIndex + boundary := writeIndex if boundary < 0 { - boundary = len(results.data) + boundary = len(data) } - for i, v := range results.data { + for i, v := range data { if i >= boundary { break } diff --git a/mtglib/internal/doppel/scout_conn_collected.go b/mtglib/internal/doppel/scout_conn_collected.go index 0fe4e4a..cdcfbeb 100644 --- a/mtglib/internal/doppel/scout_conn_collected.go +++ b/mtglib/internal/doppel/scout_conn_collected.go @@ -1,6 +1,9 @@ package doppel -import "time" +import ( + "sync" + "time" +) const ( ScoutConnCollectedPreallocSize = 100 @@ -13,23 +16,39 @@ type ScoutConnResult struct { } type ScoutConnCollected struct { + mu sync.Mutex data []ScoutConnResult writeIndex int // index at which client first wrote post-handshake data; -1 if not set } func (s *ScoutConnCollected) Add(record byte, payloadLen int) { + s.mu.Lock() s.data = append(s.data, ScoutConnResult{ timestamp: time.Now(), recordType: record, payloadLen: payloadLen, }) + s.mu.Unlock() } // MarkWrite records the current data length as the handshake boundary. func (s *ScoutConnCollected) MarkWrite() { + s.mu.Lock() if s.writeIndex < 0 { s.writeIndex = len(s.data) } + s.mu.Unlock() +} + +// Snapshot returns a copy of the collected data and the write index. +func (s *ScoutConnCollected) Snapshot() ([]ScoutConnResult, int) { + s.mu.Lock() + snapshot := make([]ScoutConnResult, len(s.data)) + copy(snapshot, s.data) + writeIndex := s.writeIndex + s.mu.Unlock() + + return snapshot, writeIndex } func NewScoutConnCollected() *ScoutConnCollected { diff --git a/mtglib/proxy_test.go b/mtglib/proxy_test.go index fc05012..278ce4f 100644 --- a/mtglib/proxy_test.go +++ b/mtglib/proxy_test.go @@ -175,7 +175,7 @@ func (suite *ProxyTestSuite) TestHTTPSRequest() { addr := fmt.Sprintf("https://%s/headers", suite.ProxyAddress()) resp, err := client.Get(addr) //nolint: noctx - suite.NoError(err) + suite.Require().NoError(err) defer resp.Body.Close() //nolint: errcheck From 73c6a3aa37315a3e6f66185f1faa8e032e915d88 Mon Sep 17 00:00:00 2001 From: Alexey Dolotov Date: Mon, 30 Mar 2026 15:00:17 +0300 Subject: [PATCH 02/10] fix: tighten ScoutConnCollected encapsulation and add concurrency test - Move error check before Snapshot() to avoid unnecessary allocation - Update existing tests to use Snapshot() instead of direct field access - Add TestConcurrentAddSnapshot to explicitly exercise the mutex --- mtglib/internal/doppel/scout.go | 8 ++- .../doppel/scout_conn_collected_test.go | 52 +++++++++++++++++-- 2 files changed, 54 insertions(+), 6 deletions(-) diff --git a/mtglib/internal/doppel/scout.go b/mtglib/internal/doppel/scout.go index d90feba..24e06a7 100644 --- a/mtglib/internal/doppel/scout.go +++ b/mtglib/internal/doppel/scout.go @@ -61,10 +61,14 @@ func (s Scout) learn(ctx context.Context, url string) (ScoutResult, error) { client.CloseIdleConnections() } + if err != nil { + return ScoutResult{}, err + } + data, writeIndex := results.Snapshot() - if err != nil || len(data) == 0 { - return ScoutResult{}, err + if len(data) == 0 { + return ScoutResult{}, nil } var result ScoutResult diff --git a/mtglib/internal/doppel/scout_conn_collected_test.go b/mtglib/internal/doppel/scout_conn_collected_test.go index dcf8a41..fad45dd 100644 --- a/mtglib/internal/doppel/scout_conn_collected_test.go +++ b/mtglib/internal/doppel/scout_conn_collected_test.go @@ -1,6 +1,7 @@ package doppel import ( + "sync" "testing" "time" @@ -16,8 +17,10 @@ func (suite *ScoutConnCollectedTestSuite) TestAddSingle() { collected := NewScoutConnCollected() collected.Add(tls.TypeApplicationData, 100) - suite.Len(collected.data, 1) - suite.Equal(byte(tls.TypeApplicationData), collected.data[0].recordType) + data, _ := collected.Snapshot() + + suite.Len(data, 1) + suite.Equal(byte(tls.TypeApplicationData), data[0].recordType) } func (suite *ScoutConnCollectedTestSuite) TestAddTimestampsAreMonotonic() { @@ -31,11 +34,52 @@ func (suite *ScoutConnCollectedTestSuite) TestAddTimestampsAreMonotonic() { time.Sleep(time.Microsecond) collected.Add(tls.TypeApplicationData, 100) - for i := 1; i < len(collected.data); i++ { - suite.True(collected.data[i].timestamp.After(collected.data[i-1].timestamp)) + data, _ := collected.Snapshot() + + for i := 1; i < len(data); i++ { + suite.True(data[i].timestamp.After(data[i-1].timestamp)) } } +func (suite *ScoutConnCollectedTestSuite) TestConcurrentAddSnapshot() { + collected := NewScoutConnCollected() + + var wg sync.WaitGroup + + wg.Add(3) + + go func() { + defer wg.Done() + + for i := 0; i < 1000; i++ { + collected.Add(tls.TypeApplicationData, i) + } + }() + + go func() { + defer wg.Done() + + for i := 0; i < 100; i++ { + collected.MarkWrite() + } + }() + + go func() { + defer wg.Done() + + for i := 0; i < 1000; i++ { + data, _ := collected.Snapshot() + _ = len(data) + } + }() + + wg.Wait() + + data, writeIndex := collected.Snapshot() + suite.Len(data, 1000) + suite.GreaterOrEqual(writeIndex, 0) +} + func TestScoutConnCollected(t *testing.T) { t.Parallel() suite.Run(t, &ScoutConnCollectedTestSuite{}) From eedee631430b5b3b9212a48f03280ca50bae4cc5 Mon Sep 17 00:00:00 2001 From: dolonet Date: Mon, 30 Mar 2026 16:17:51 +0000 Subject: [PATCH 03/10] Address review: use slices.Clone, simplify concurrent test - Replace manual make+copy with slices.Clone in Snapshot() - Remove redundant _ = len(data); Snapshot() call alone is sufficient to exercise the lock under -race --- mtglib/internal/doppel/scout_conn_collected.go | 4 ++-- mtglib/internal/doppel/scout_conn_collected_test.go | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/mtglib/internal/doppel/scout_conn_collected.go b/mtglib/internal/doppel/scout_conn_collected.go index cdcfbeb..5a4de92 100644 --- a/mtglib/internal/doppel/scout_conn_collected.go +++ b/mtglib/internal/doppel/scout_conn_collected.go @@ -1,6 +1,7 @@ package doppel import ( + "slices" "sync" "time" ) @@ -43,8 +44,7 @@ func (s *ScoutConnCollected) MarkWrite() { // Snapshot returns a copy of the collected data and the write index. func (s *ScoutConnCollected) Snapshot() ([]ScoutConnResult, int) { s.mu.Lock() - snapshot := make([]ScoutConnResult, len(s.data)) - copy(snapshot, s.data) + snapshot := slices.Clone(s.data) writeIndex := s.writeIndex s.mu.Unlock() diff --git a/mtglib/internal/doppel/scout_conn_collected_test.go b/mtglib/internal/doppel/scout_conn_collected_test.go index fad45dd..e49f6bd 100644 --- a/mtglib/internal/doppel/scout_conn_collected_test.go +++ b/mtglib/internal/doppel/scout_conn_collected_test.go @@ -68,8 +68,8 @@ func (suite *ScoutConnCollectedTestSuite) TestConcurrentAddSnapshot() { defer wg.Done() for i := 0; i < 1000; i++ { - data, _ := collected.Snapshot() - _ = len(data) + // call Snapshot concurrently to exercise the lock under -race + collected.Snapshot() //nolint:errcheck } }() From 1fcec38aeaba82de9a7c3a04f06cc86a4145235a Mon Sep 17 00:00:00 2001 From: 9seconds Date: Tue, 31 Mar 2026 11:05:49 +0200 Subject: [PATCH 04/10] Change IP address set priority For a couple of releases we use collected IPs as a prioritized source for connecting to Telegram. But apparently, they work way worse than it should, and having connectivity to core ip ALWAYS gives better results. Thus, this PR flips priorities, so users could have auto-update enabled as a source of secondary addresses, not primary ones --- mtglib/internal/dc/view.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/mtglib/internal/dc/view.go b/mtglib/internal/dc/view.go index e4980a7..9f9e76d 100644 --- a/mtglib/internal/dc/view.go +++ b/mtglib/internal/dc/view.go @@ -5,15 +5,19 @@ type dcView struct { } func (d dcView) getV4(dc int) []Addr { - addrs := d.publicConfigs.getV4(dc) + var addrs []Addr + addrs = append(addrs, defaultDCAddrSet.getV4(dc)...) + addrs = append(addrs, d.publicConfigs.getV4(dc)...) return addrs } func (d dcView) getV6(dc int) []Addr { - addrs := d.publicConfigs.getV6(dc) + var addrs []Addr + addrs = append(addrs, defaultDCAddrSet.getV6(dc)...) + addrs = append(addrs, d.publicConfigs.getV6(dc)...) return addrs } From b6427ee321e32423381fa2dfd35465a70bcf58b5 Mon Sep 17 00:00:00 2001 From: 9seconds Date: Tue, 31 Mar 2026 15:07:01 +0200 Subject: [PATCH 05/10] More idiomatic Golang --- internal/config/config.go | 8 ++++---- internal/config/parse.go | 8 ++++---- mtglib/conns.go | 31 ++++++++++++++----------------- mtglib/proxy_opts.go | 1 - 4 files changed, 22 insertions(+), 26 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 4ca566c..dcc3186 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -52,10 +52,10 @@ type Config struct { Blocklist ListConfig `json:"blocklist"` Allowlist ListConfig `json:"allowlist"` Doppelganger struct { - URLs []TypeHttpsURL `json:"urls"` - Repeats TypeConcurrency `json:"repeats_per_raid"` - UpdateEach TypeDuration `json:"raid_each"` - DRS TypeBool `json:"drs"` + URLs []TypeHttpsURL `json:"urls"` + Repeats TypeConcurrency `json:"repeats_per_raid"` + UpdateEach TypeDuration `json:"raid_each"` + DRS TypeBool `json:"drs"` } `json:"doppelganger"` } `json:"defense"` Network struct { diff --git a/internal/config/parse.go b/internal/config/parse.go index fd60708..6bd16f7 100644 --- a/internal/config/parse.go +++ b/internal/config/parse.go @@ -47,10 +47,10 @@ type tomlConfig struct { UpdateEach string `toml:"update-each" json:"updateEach,omitempty"` } `toml:"allowlist" json:"allowlist,omitempty"` Doppelganger struct { - URLs []string `toml:"urls" json:"urls,omitempty"` - Repeats uint `toml:"repeats-per-raid" json:"repeats_per_raid,omitempty"` - UpdateEach string `toml:"raid-each" json:"raid_each,omitempty"` - DRS bool `toml:"drs" json:"drs,omitempty"` + URLs []string `toml:"urls" json:"urls,omitempty"` + Repeats uint `toml:"repeats-per-raid" json:"repeats_per_raid,omitempty"` + UpdateEach string `toml:"raid-each" json:"raid_each,omitempty"` + DRS bool `toml:"drs" json:"drs,omitempty"` } `toml:"doppelganger" json:"doppelganger,omitempty"` } `toml:"defense" json:"defense,omitempty"` Network struct { diff --git a/mtglib/conns.go b/mtglib/conns.go index 55d57ee..b271d5b 100644 --- a/mtglib/conns.go +++ b/mtglib/conns.go @@ -3,6 +3,7 @@ package mtglib import ( "bytes" "context" + "errors" "fmt" "io" "net" @@ -102,7 +103,7 @@ func newConnProxyProtocol(source, target essentials.Conn) *connProxyProtocol { // Both directions update the same timestamp so that activity in one direction // prevents the other (idle) direction from timing out. type idleTracker struct { - lastActive atomic.Int64 // unix nanos + lastActive atomic.Pointer[time.Time] timeout time.Duration } @@ -114,13 +115,12 @@ func newIdleTracker(timeout time.Duration) *idleTracker { } func (t *idleTracker) touch() { - t.lastActive.Store(time.Now().UnixNano()) + stamp := time.Now() + t.lastActive.Store(&stamp) } func (t *idleTracker) isIdle() bool { - last := time.Unix(0, t.lastActive.Load()) - - return time.Since(last) >= t.timeout + return time.Since(*t.lastActive.Load()) >= t.timeout } type connIdleTimeout struct { @@ -130,25 +130,22 @@ type connIdleTimeout struct { } func (c connIdleTimeout) Read(b []byte) (int, error) { + var netErr net.Error + for { c.SetReadDeadline(time.Now().Add(c.tracker.timeout)) //nolint: errcheck n, err := c.Conn.Read(b) - if n > 0 { + + switch { + case err == nil: c.tracker.touch() - - return n, err //nolint: wrapcheck + return n, nil + case errors.As(err, &netErr) && netErr.Timeout() && !c.tracker.isIdle(): + continue } - if err != nil { - if netErr, ok := err.(net.Error); ok && netErr.Timeout() && !c.tracker.isIdle() { //nolint: errorlint - continue - } - - return 0, err //nolint: wrapcheck - } - - return 0, nil + return n, err } } diff --git a/mtglib/proxy_opts.go b/mtglib/proxy_opts.go index 102b279..fd44783 100644 --- a/mtglib/proxy_opts.go +++ b/mtglib/proxy_opts.go @@ -160,7 +160,6 @@ type ProxyOpts struct { // DoppelGangerDRS defines if TLS Dynamic Record Sizing is active. DoppelGangerDRS bool - } func (p ProxyOpts) valid() error { From 2aa3321bd4623035ab73ec9b1b87377256ca7c11 Mon Sep 17 00:00:00 2001 From: 9seconds Date: Tue, 31 Mar 2026 19:03:48 +0200 Subject: [PATCH 06/10] Add more forks --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 7b5021c..5314e72 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,8 @@ are the most notable: * [Official](https://github.com/TelegramMessenger/MTProxy) * [Python](https://github.com/alexbers/mtprotoproxy) * [Erlang](https://github.com/seriyps/mtproto_proxy) +* [Teleproxy (C)](https://github.com/teleproxy/teleproxy) +* [mtproto.zig (Zig)](https://github.com/sleep3r/mtproto.zig) * [Telemt (Rust)](https://github.com/telemt/telemt) You can use any of these. They work great and all implementations have From a3663fe8b5e95834a45d0e7fb48af9bddae875a4 Mon Sep 17 00:00:00 2001 From: 9seconds Date: Tue, 31 Mar 2026 22:13:42 +0200 Subject: [PATCH 07/10] Increase timeout for CI artifacts build --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index a71e3a2..161d45f 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -122,7 +122,7 @@ jobs: artifacts: name: Build release artifacts runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 20 steps: - name: Checkout uses: actions/checkout@v6 From 38abee7d7f796ffaf6716985a0dcc6c3fe4a8f1a Mon Sep 17 00:00:00 2001 From: appolimp Date: Wed, 1 Apr 2026 07:09:49 +0300 Subject: [PATCH 08/10] Support fragmented TLS handshake records MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DPI bypass tools like ByeDPI fragment a single TLS record into multiple records to evade censorship. This broke ReadClientHello because it assumed the entire ClientHello arrives in one TLS record. Add reassembleTLSHandshake that reads continuation records and reconstructs a single TLS record before parsing and HMAC verification. Per RFC 5246 Section 6.2.1, handshake messages may be fragmented across multiple records — this is valid TLS behavior. --- Dockerfile | 2 +- mtglib/internal/tls/fake/client_side.go | 138 ++++++++-- mtglib/internal/tls/fake/client_side_test.go | 272 +++++++++++++++++++ 3 files changed, 395 insertions(+), 17 deletions(-) diff --git a/Dockerfile b/Dockerfile index e39ac6f..394601a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -33,7 +33,7 @@ RUN go mod download COPY . /app RUN set -x \ - && version="$(git describe --exact-match HEAD 2>/dev/null || git describe --tags --always)" \ + && version="$(git describe --exact-match HEAD 2>/dev/null || git describe --tags --always 2>/dev/null || echo dev)" \ && go build \ -trimpath \ -mod=readonly \ diff --git a/mtglib/internal/tls/fake/client_side.go b/mtglib/internal/tls/fake/client_side.go index 3b7e5a0..737ab84 100644 --- a/mtglib/internal/tls/fake/client_side.go +++ b/mtglib/internal/tls/fake/client_side.go @@ -6,6 +6,7 @@ import ( "crypto/sha256" "crypto/subtle" "encoding/binary" + "errors" "fmt" "io" "net" @@ -23,6 +24,11 @@ const ( RandomOffset = 1 + 2 + 2 + 1 + 3 + 2 sniDNSNamesListType = 0 + + // maxContinuationRecords limits the number of continuation TLS records + // that reassembleTLSHandshake will read. This prevents resource exhaustion + // from adversarial fragmentation. + maxContinuationRecords = 10 ) var ( @@ -56,12 +62,18 @@ func ReadClientHello( // 4. New digest should be all 0 except of last 4 bytes // 5. Last 4 bytes are little endian uint32 of UNIX timestamp when // this message was created. - handshakeCopyBuf := &bytes.Buffer{} - reader := io.TeeReader(conn, handshakeCopyBuf) - - reader, err := parseTLSHeader(reader) + reassembled, err := reassembleTLSHandshake(conn) if err != nil { - return nil, fmt.Errorf("cannot parse tls header: %w", err) + return nil, fmt.Errorf("cannot reassemble TLS records: %w", err) + } + + handshakeCopyBuf := &bytes.Buffer{} + reader := io.TeeReader(reassembled, handshakeCopyBuf) + + // Skip the TLS record header (validated during reassembly). + // The header still flows through TeeReader into handshakeCopyBuf for HMAC. + if _, err = io.CopyN(io.Discard, reader, tls.SizeHeader); err != nil { + return nil, fmt.Errorf("cannot skip tls header: %w", err) } reader, err = parseHandshakeHeader(reader) @@ -110,17 +122,30 @@ func ReadClientHello( return hello, nil } -func parseTLSHeader(r io.Reader) (io.Reader, error) { - // record_type(1) + version(2) + size(2) - // 16 - type is 0x16 (handshake record) - // 03 01 - protocol version is "3,1" (also known as TLS 1.0) - // 00 f8 - 0xF8 (248) bytes of handshake message follows - header := [1 + 2 + 2]byte{} +// reassembleTLSHandshake reads one or more TLS records from conn, +// validates the record type and version, and reassembles fragmented +// handshake payloads into a single TLS record. +// +// Per RFC 5246 Section 6.2.1, handshake messages may be fragmented +// across multiple TLS records. DPI bypass tools like ByeDPI use this +// to evade censorship. +// +// The returned buffer contains the full TLS record (header + payload) +// so that callers can include the header in HMAC computation. +func reassembleTLSHandshake(conn io.Reader) (*bytes.Buffer, error) { + header := [tls.SizeHeader]byte{} - if _, err := io.ReadFull(r, header[:]); err != nil { + if _, err := io.ReadFull(conn, header[:]); err != nil { return nil, fmt.Errorf("cannot read record header: %w", err) } + length := int64(binary.BigEndian.Uint16(header[3:])) + payload := &bytes.Buffer{} + + if _, err := io.CopyN(payload, conn, length); err != nil { + return nil, fmt.Errorf("cannot read record payload: %w", err) + } + if header[0] != tls.TypeHandshake { return nil, fmt.Errorf("unexpected record type %#x", header[0]) } @@ -129,12 +154,93 @@ func parseTLSHeader(r io.Reader) (io.Reader, error) { return nil, fmt.Errorf("unexpected protocol version %#x %#x", header[1], header[2]) } - length := int64(binary.BigEndian.Uint16(header[3:])) - buf := &bytes.Buffer{} + // Reassemble fragmented payload. continuationCount caps the total + // number of continuation records across both phases below. + continuationCount := 0 - _, err := io.CopyN(buf, r, length) + // Phase 1: read continuation records until we have at least the + // 4-byte handshake header (type + uint24 length) to determine the + // expected total size. + for ; payload.Len() < 4 && continuationCount < maxContinuationRecords; continuationCount++ { + prevLen := payload.Len() - return buf, err + if err := readContinuationRecord(conn, payload); err != nil { + payload.Truncate(prevLen) // discard partial data on error + + if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) { + break // no more records — let downstream parsing handle what we have + } + + return nil, err + } + } + + // Phase 2: we know the expected handshake size — read remaining + // continuation records until the payload is complete. + if payload.Len() >= 4 { + p := payload.Bytes() + expectedTotal := 4 + (int(p[1])<<16 | int(p[2])<<8 | int(p[3])) + + if expectedTotal > 0xFFFF { + return nil, fmt.Errorf("handshake message too large: %d bytes", expectedTotal) + } + + for ; payload.Len() < expectedTotal && continuationCount < maxContinuationRecords; continuationCount++ { + if err := readContinuationRecord(conn, payload); err != nil { + return nil, err + } + } + + if payload.Len() < expectedTotal { + return nil, fmt.Errorf("cannot reassemble handshake: too many continuation records") + } + + payload.Truncate(expectedTotal) + } + + if payload.Len() > 0xFFFF { + return nil, fmt.Errorf("reassembled payload too large: %d bytes", payload.Len()) + } + + // Reconstruct a single TLS record with the reassembled payload. + result := &bytes.Buffer{} + result.Grow(tls.SizeHeader + payload.Len()) + result.Write(header[:3]) + binary.Write(result, binary.BigEndian, uint16(payload.Len())) //nolint:errcheck // bytes.Buffer.Write never fails + result.Write(payload.Bytes()) + + return result, nil +} + +// readContinuationRecord reads the next TLS record header and appends its +// full payload to dst. It returns an error if the record is not a handshake +// record. +func readContinuationRecord(conn io.Reader, dst *bytes.Buffer) error { + nextHeader := [tls.SizeHeader]byte{} + + if _, err := io.ReadFull(conn, nextHeader[:]); err != nil { + return fmt.Errorf("cannot read continuation record header: %w", err) + } + + if nextHeader[0] != tls.TypeHandshake { + return fmt.Errorf("unexpected continuation record type %#x", nextHeader[0]) + } + + if nextHeader[1] != 3 || nextHeader[2] != 1 { + return fmt.Errorf("unexpected continuation record version %#x %#x", nextHeader[1], nextHeader[2]) + } + + nextLength := int64(binary.BigEndian.Uint16(nextHeader[3:])) + + if nextLength == 0 { + return fmt.Errorf("zero-length continuation record") + } + + if _, err := io.CopyN(dst, conn, nextLength); err != nil { + return fmt.Errorf("cannot read continuation record payload: %w", err) + } + + return nil } func parseHandshakeHeader(r io.Reader) (io.Reader, error) { diff --git a/mtglib/internal/tls/fake/client_side_test.go b/mtglib/internal/tls/fake/client_side_test.go index 2e66b6c..ed30eaa 100644 --- a/mtglib/internal/tls/fake/client_side_test.go +++ b/mtglib/internal/tls/fake/client_side_test.go @@ -3,8 +3,10 @@ package fake_test import ( "bytes" "encoding/binary" + "encoding/json" "errors" "io" + "os" "testing" "time" @@ -393,3 +395,273 @@ func TestParseClientHelloSNI(t *testing.T) { t.Parallel() suite.Run(t, &ParseClientHelloSNITestSuite{}) } + +// fragmentTLSRecord splits a single TLS record into n TLS records by +// dividing the payload into roughly equal parts. Each part gets its own +// TLS record header with the same record type and version. +func fragmentTLSRecord(t testing.TB, full []byte, n int) []byte { + t.Helper() + + recordType := full[0] + version := full[1:3] + payload := full[tls.SizeHeader:] + + chunkSize := len(payload) / n + result := &bytes.Buffer{} + + for i := 0; i < n; i++ { + start := i * chunkSize + end := start + chunkSize + + if i == n-1 { + end = len(payload) + } + + chunk := payload[start:end] + result.WriteByte(recordType) + result.Write(version) + require.NoError(t, binary.Write(result, binary.BigEndian, uint16(len(chunk)))) + result.Write(chunk) + } + + return result.Bytes() +} + +// splitPayloadAt creates two TLS records from a single record by splitting +// the payload at the given byte position. +func splitPayloadAt(t testing.TB, full []byte, pos int) []byte { + t.Helper() + + payload := full[tls.SizeHeader:] + buf := &bytes.Buffer{} + + buf.WriteByte(tls.TypeHandshake) + buf.Write(full[1:3]) + require.NoError(t, binary.Write(buf, binary.BigEndian, uint16(pos))) + buf.Write(payload[:pos]) + + buf.WriteByte(tls.TypeHandshake) + buf.Write(full[1:3]) + require.NoError(t, binary.Write(buf, binary.BigEndian, uint16(len(payload)-pos))) + buf.Write(payload[pos:]) + + return buf.Bytes() +} + +type ParseClientHelloFragmentedTestSuite struct { + suite.Suite + + secret mtglib.Secret + snapshot *clientHelloSnapshot +} + +func (s *ParseClientHelloFragmentedTestSuite) SetupSuite() { + parsed, err := mtglib.ParseSecret( + "ee367a189aee18fa31c190054efd4a8e9573746f726167652e676f6f676c65617069732e636f6d", + ) + require.NoError(s.T(), err) + + s.secret = parsed + + fileData, err := os.ReadFile("testdata/client-hello-ok-19dfe38384b9884b.json") + require.NoError(s.T(), err) + + s.snapshot = &clientHelloSnapshot{} + require.NoError(s.T(), json.Unmarshal(fileData, s.snapshot)) +} + +func (s *ParseClientHelloFragmentedTestSuite) makeConn(data []byte) *parseClientHelloConnMock { + readBuf := &bytes.Buffer{} + readBuf.Write(data) + + connMock := &parseClientHelloConnMock{ + readBuf: readBuf, + } + + connMock. + On("SetReadDeadline", mock.AnythingOfType("time.Time")). + Twice(). + Return(nil) + + return connMock +} + +func (s *ParseClientHelloFragmentedTestSuite) TestReassemblySuccess() { + full := s.snapshot.GetFull() + + tests := []struct { + name string + data []byte + }{ + {"two equal fragments", fragmentTLSRecord(s.T(), full, 2)}, + {"three equal fragments", fragmentTLSRecord(s.T(), full, 3)}, + {"single byte first fragment", splitPayloadAt(s.T(), full, 1)}, + {"three byte first fragment", splitPayloadAt(s.T(), full, 3)}, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + connMock := s.makeConn(tt.data) + defer connMock.AssertExpectations(s.T()) + + hello, err := fake.ReadClientHello( + connMock, + s.secret.Key[:], + s.secret.Host, + TolerateTime, + ) + s.Require().NoError(err) + + s.Equal(s.snapshot.GetRandom(), hello.Random[:]) + s.Equal(s.snapshot.GetSessionID(), hello.SessionID) + s.Equal(uint16(s.snapshot.CipherSuite), hello.CipherSuite) + }) + } +} + +func (s *ParseClientHelloFragmentedTestSuite) TestReassemblyErrors() { + full := s.snapshot.GetFull() + payload := full[tls.SizeHeader:] + + tests := []struct { + name string + buildData func() []byte + errMsg string + }{ + { + name: "wrong continuation record type", + buildData: func() []byte { + buf := &bytes.Buffer{} + buf.WriteByte(tls.TypeHandshake) + buf.Write(full[1:3]) + require.NoError(s.T(), binary.Write(buf, binary.BigEndian, uint16(10))) + buf.Write(payload[:10]) + // Wrong type: application data instead of handshake + buf.WriteByte(tls.TypeApplicationData) + buf.Write(full[1:3]) + require.NoError(s.T(), binary.Write(buf, binary.BigEndian, uint16(len(payload)-10))) + buf.Write(payload[10:]) + return buf.Bytes() + }, + errMsg: "unexpected continuation record type", + }, + { + name: "too many continuation records", + buildData: func() []byte { + // Handshake header claiming 256 bytes, but we only send 1 byte per continuation + handshakePayload := []byte{0x01, 0x00, 0x01, 0x00} + buf := &bytes.Buffer{} + buf.WriteByte(tls.TypeHandshake) + buf.Write([]byte{3, 1}) + require.NoError(s.T(), binary.Write(buf, binary.BigEndian, uint16(len(handshakePayload)))) + buf.Write(handshakePayload) + for range 11 { + buf.WriteByte(tls.TypeHandshake) + buf.Write([]byte{3, 1}) + require.NoError(s.T(), binary.Write(buf, binary.BigEndian, uint16(1))) + buf.WriteByte(0xAB) + } + return buf.Bytes() + }, + errMsg: "too many continuation records", + }, + { + name: "zero-length continuation record", + buildData: func() []byte { + buf := &bytes.Buffer{} + buf.WriteByte(tls.TypeHandshake) + buf.Write(full[1:3]) + require.NoError(s.T(), binary.Write(buf, binary.BigEndian, uint16(10))) + buf.Write(payload[:10]) + // Valid header but zero-length payload + buf.WriteByte(tls.TypeHandshake) + buf.Write(full[1:3]) + require.NoError(s.T(), binary.Write(buf, binary.BigEndian, uint16(0))) + return buf.Bytes() + }, + errMsg: "zero-length continuation record", + }, + { + name: "wrong continuation record version", + buildData: func() []byte { + buf := &bytes.Buffer{} + buf.WriteByte(tls.TypeHandshake) + buf.Write(full[1:3]) + require.NoError(s.T(), binary.Write(buf, binary.BigEndian, uint16(10))) + buf.Write(payload[:10]) + // Wrong version: 3.3 instead of 3.1 + buf.WriteByte(tls.TypeHandshake) + buf.Write([]byte{3, 3}) + require.NoError(s.T(), binary.Write(buf, binary.BigEndian, uint16(len(payload)-10))) + buf.Write(payload[10:]) + return buf.Bytes() + }, + errMsg: "unexpected continuation record version", + }, + { + name: "handshake message too large", + buildData: func() []byte { + // Handshake header claiming 0x010000 (65536) bytes — exceeds 0xFFFF limit + handshakePayload := []byte{0x01, 0x01, 0x00, 0x00} + buf := &bytes.Buffer{} + buf.WriteByte(tls.TypeHandshake) + buf.Write([]byte{3, 1}) + require.NoError(s.T(), binary.Write(buf, binary.BigEndian, uint16(len(handshakePayload)))) + buf.Write(handshakePayload) + return buf.Bytes() + }, + errMsg: "handshake message too large", + }, + { + name: "truncated continuation record header", + buildData: func() []byte { + buf := &bytes.Buffer{} + buf.WriteByte(tls.TypeHandshake) + buf.Write(full[1:3]) + require.NoError(s.T(), binary.Write(buf, binary.BigEndian, uint16(10))) + buf.Write(payload[:10]) + // Connection ends mid-header (only 2 bytes) + buf.WriteByte(tls.TypeHandshake) + buf.WriteByte(3) + return buf.Bytes() + }, + errMsg: "cannot read continuation record header", + }, + { + name: "truncated continuation record payload", + buildData: func() []byte { + buf := &bytes.Buffer{} + buf.WriteByte(tls.TypeHandshake) + buf.Write(full[1:3]) + require.NoError(s.T(), binary.Write(buf, binary.BigEndian, uint16(10))) + buf.Write(payload[:10]) + // Claims 100 bytes but no payload follows + buf.WriteByte(tls.TypeHandshake) + buf.Write(full[1:3]) + require.NoError(s.T(), binary.Write(buf, binary.BigEndian, uint16(100))) + return buf.Bytes() + }, + errMsg: "cannot read continuation record payload", + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + connMock := s.makeConn(tt.buildData()) + defer connMock.AssertExpectations(s.T()) + + _, err := fake.ReadClientHello( + connMock, + s.secret.Key[:], + s.secret.Host, + TolerateTime, + ) + s.ErrorContains(err, tt.errMsg) + }) + } +} + +func TestParseClientHelloFragmented(t *testing.T) { + t.Parallel() + suite.Run(t, &ParseClientHelloFragmentedTestSuite{}) +} From f4f969e702fcc0abb5ffd27da5f0d61a01348f92 Mon Sep 17 00:00:00 2001 From: 9seconds Date: Wed, 1 Apr 2026 14:01:24 +0200 Subject: [PATCH 09/10] Refactor TLS fragmenting --- mtglib/internal/tls/fake/client_side.go | 182 +------------------ mtglib/internal/tls/fake/client_side_test.go | 14 +- mtglib/internal/tls/fake/utils.go | 158 ++++++++++++++++ 3 files changed, 172 insertions(+), 182 deletions(-) create mode 100644 mtglib/internal/tls/fake/utils.go diff --git a/mtglib/internal/tls/fake/client_side.go b/mtglib/internal/tls/fake/client_side.go index 737ab84..66542bc 100644 --- a/mtglib/internal/tls/fake/client_side.go +++ b/mtglib/internal/tls/fake/client_side.go @@ -6,14 +6,11 @@ import ( "crypto/sha256" "crypto/subtle" "encoding/binary" - "errors" "fmt" "io" "net" "slices" "time" - - "github.com/9seconds/mtg/v2/mtglib/internal/tls" ) const ( @@ -24,11 +21,6 @@ const ( RandomOffset = 1 + 2 + 2 + 1 + 3 + 2 sniDNSNamesListType = 0 - - // maxContinuationRecords limits the number of continuation TLS records - // that reassembleTLSHandshake will read. This prevents resource exhaustion - // from adversarial fragmentation. - maxContinuationRecords = 10 ) var ( @@ -62,31 +54,17 @@ func ReadClientHello( // 4. New digest should be all 0 except of last 4 bytes // 5. Last 4 bytes are little endian uint32 of UNIX timestamp when // this message was created. - reassembled, err := reassembleTLSHandshake(conn) + clientHelloCopy, handshakeReader, err := parseClientHello(conn) if err != nil { - return nil, fmt.Errorf("cannot reassemble TLS records: %w", err) + return nil, fmt.Errorf("cannot read client hello: %w", err) } - handshakeCopyBuf := &bytes.Buffer{} - reader := io.TeeReader(reassembled, handshakeCopyBuf) - - // Skip the TLS record header (validated during reassembly). - // The header still flows through TeeReader into handshakeCopyBuf for HMAC. - if _, err = io.CopyN(io.Discard, reader, tls.SizeHeader); err != nil { - return nil, fmt.Errorf("cannot skip tls header: %w", err) - } - - reader, err = parseHandshakeHeader(reader) - if err != nil { - return nil, fmt.Errorf("cannot parse handshake header: %w", err) - } - - hello, err := parseHandshake(reader) + hello, err := parseHandshake(handshakeReader) if err != nil { return nil, fmt.Errorf("cannot parse handshake: %w", err) } - sniHostnames, err := parseSNI(reader) + sniHostnames, err := parseSNI(handshakeReader) if err != nil { return nil, fmt.Errorf("cannot parse SNI: %w", err) } @@ -97,10 +75,10 @@ func ReadClientHello( digest := hmac.New(sha256.New, secret) // we write a copy of the handshake with client random all nullified. - digest.Write(handshakeCopyBuf.Next(RandomOffset)) - handshakeCopyBuf.Next(RandomLen) + digest.Write(clientHelloCopy.Next(RandomOffset)) + clientHelloCopy.Next(RandomLen) digest.Write(emptyRandom[:]) - digest.Write(handshakeCopyBuf.Bytes()) + digest.Write(clientHelloCopy.Bytes()) computed := digest.Sum(nil) @@ -122,152 +100,6 @@ func ReadClientHello( return hello, nil } -// reassembleTLSHandshake reads one or more TLS records from conn, -// validates the record type and version, and reassembles fragmented -// handshake payloads into a single TLS record. -// -// Per RFC 5246 Section 6.2.1, handshake messages may be fragmented -// across multiple TLS records. DPI bypass tools like ByeDPI use this -// to evade censorship. -// -// The returned buffer contains the full TLS record (header + payload) -// so that callers can include the header in HMAC computation. -func reassembleTLSHandshake(conn io.Reader) (*bytes.Buffer, error) { - header := [tls.SizeHeader]byte{} - - if _, err := io.ReadFull(conn, header[:]); err != nil { - return nil, fmt.Errorf("cannot read record header: %w", err) - } - - length := int64(binary.BigEndian.Uint16(header[3:])) - payload := &bytes.Buffer{} - - if _, err := io.CopyN(payload, conn, length); err != nil { - return nil, fmt.Errorf("cannot read record payload: %w", err) - } - - if header[0] != tls.TypeHandshake { - return nil, fmt.Errorf("unexpected record type %#x", header[0]) - } - - if header[1] != 3 || header[2] != 1 { - return nil, fmt.Errorf("unexpected protocol version %#x %#x", header[1], header[2]) - } - - // Reassemble fragmented payload. continuationCount caps the total - // number of continuation records across both phases below. - continuationCount := 0 - - // Phase 1: read continuation records until we have at least the - // 4-byte handshake header (type + uint24 length) to determine the - // expected total size. - for ; payload.Len() < 4 && continuationCount < maxContinuationRecords; continuationCount++ { - prevLen := payload.Len() - - if err := readContinuationRecord(conn, payload); err != nil { - payload.Truncate(prevLen) // discard partial data on error - - if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) { - break // no more records — let downstream parsing handle what we have - } - - return nil, err - } - } - - // Phase 2: we know the expected handshake size — read remaining - // continuation records until the payload is complete. - if payload.Len() >= 4 { - p := payload.Bytes() - expectedTotal := 4 + (int(p[1])<<16 | int(p[2])<<8 | int(p[3])) - - if expectedTotal > 0xFFFF { - return nil, fmt.Errorf("handshake message too large: %d bytes", expectedTotal) - } - - for ; payload.Len() < expectedTotal && continuationCount < maxContinuationRecords; continuationCount++ { - if err := readContinuationRecord(conn, payload); err != nil { - return nil, err - } - } - - if payload.Len() < expectedTotal { - return nil, fmt.Errorf("cannot reassemble handshake: too many continuation records") - } - - payload.Truncate(expectedTotal) - } - - if payload.Len() > 0xFFFF { - return nil, fmt.Errorf("reassembled payload too large: %d bytes", payload.Len()) - } - - // Reconstruct a single TLS record with the reassembled payload. - result := &bytes.Buffer{} - result.Grow(tls.SizeHeader + payload.Len()) - result.Write(header[:3]) - binary.Write(result, binary.BigEndian, uint16(payload.Len())) //nolint:errcheck // bytes.Buffer.Write never fails - result.Write(payload.Bytes()) - - return result, nil -} - -// readContinuationRecord reads the next TLS record header and appends its -// full payload to dst. It returns an error if the record is not a handshake -// record. -func readContinuationRecord(conn io.Reader, dst *bytes.Buffer) error { - nextHeader := [tls.SizeHeader]byte{} - - if _, err := io.ReadFull(conn, nextHeader[:]); err != nil { - return fmt.Errorf("cannot read continuation record header: %w", err) - } - - if nextHeader[0] != tls.TypeHandshake { - return fmt.Errorf("unexpected continuation record type %#x", nextHeader[0]) - } - - if nextHeader[1] != 3 || nextHeader[2] != 1 { - return fmt.Errorf("unexpected continuation record version %#x %#x", nextHeader[1], nextHeader[2]) - } - - nextLength := int64(binary.BigEndian.Uint16(nextHeader[3:])) - - if nextLength == 0 { - return fmt.Errorf("zero-length continuation record") - } - - if _, err := io.CopyN(dst, conn, nextLength); err != nil { - return fmt.Errorf("cannot read continuation record payload: %w", err) - } - - return nil -} - -func parseHandshakeHeader(r io.Reader) (io.Reader, error) { - // type(1) + size(3 / uint24) - // 01 - handshake message type 0x01 (client hello) - // 00 00 f4 - 0xF4 (244) bytes of client hello data follows - header := [1 + 3]byte{} - - if _, err := io.ReadFull(r, header[:]); err != nil { - return nil, fmt.Errorf("cannot read handshake header: %w", err) - } - - if header[0] != TypeHandshakeClient { - return nil, fmt.Errorf("incorrect handshake type: %#x", header[0]) - } - - // unfortunately there is not uint24 in golang, so we just reust header - header[0] = 0 - - length := int64(binary.BigEndian.Uint32(header[:])) - buf := &bytes.Buffer{} - - _, err := io.CopyN(buf, r, length) - - return buf, err -} - func parseHandshake(r io.Reader) (*ClientHello, error) { // A protocol version of "3,3" (meaning TLS 1.2) is given. header := [2]byte{} diff --git a/mtglib/internal/tls/fake/client_side_test.go b/mtglib/internal/tls/fake/client_side_test.go index ed30eaa..bf23409 100644 --- a/mtglib/internal/tls/fake/client_side_test.go +++ b/mtglib/internal/tls/fake/client_side_test.go @@ -543,7 +543,7 @@ func (s *ParseClientHelloFragmentedTestSuite) TestReassemblyErrors() { buf.Write(payload[10:]) return buf.Bytes() }, - errMsg: "unexpected continuation record type", + errMsg: "unexpected record type", }, { name: "too many continuation records", @@ -563,7 +563,7 @@ func (s *ParseClientHelloFragmentedTestSuite) TestReassemblyErrors() { } return buf.Bytes() }, - errMsg: "too many continuation records", + errMsg: "too many fragments", }, { name: "zero-length continuation record", @@ -579,7 +579,7 @@ func (s *ParseClientHelloFragmentedTestSuite) TestReassemblyErrors() { require.NoError(s.T(), binary.Write(buf, binary.BigEndian, uint16(0))) return buf.Bytes() }, - errMsg: "zero-length continuation record", + errMsg: "cannot read record header", }, { name: "wrong continuation record version", @@ -596,7 +596,7 @@ func (s *ParseClientHelloFragmentedTestSuite) TestReassemblyErrors() { buf.Write(payload[10:]) return buf.Bytes() }, - errMsg: "unexpected continuation record version", + errMsg: "unexpected protocol version", }, { name: "handshake message too large", @@ -610,7 +610,7 @@ func (s *ParseClientHelloFragmentedTestSuite) TestReassemblyErrors() { buf.Write(handshakePayload) return buf.Bytes() }, - errMsg: "handshake message too large", + errMsg: "cannot read record header", }, { name: "truncated continuation record header", @@ -625,7 +625,7 @@ func (s *ParseClientHelloFragmentedTestSuite) TestReassemblyErrors() { buf.WriteByte(3) return buf.Bytes() }, - errMsg: "cannot read continuation record header", + errMsg: "cannot read record header", }, { name: "truncated continuation record payload", @@ -641,7 +641,7 @@ func (s *ParseClientHelloFragmentedTestSuite) TestReassemblyErrors() { require.NoError(s.T(), binary.Write(buf, binary.BigEndian, uint16(100))) return buf.Bytes() }, - errMsg: "cannot read continuation record payload", + errMsg: "EOF", }, } diff --git a/mtglib/internal/tls/fake/utils.go b/mtglib/internal/tls/fake/utils.go new file mode 100644 index 0000000..5e92711 --- /dev/null +++ b/mtglib/internal/tls/fake/utils.go @@ -0,0 +1,158 @@ +package fake + +import ( + "bytes" + "encoding/binary" + "errors" + "fmt" + "io" + + "github.com/9seconds/mtg/v2/mtglib/internal/tls" +) + +const ( + maxFragmentsCount = 10 +) + +var ErrTooManyFragments = errors.New("too many fragments") + +// https://datatracker.ietf.org/doc/html/rfc5246#section-6.2.1 +// client hello can be fragmented in a series of packets: +// +// Bytes on the wire: +// +// 16 03 01 00 F8 01 00 00 F4 03 03 [32 bytes random] [session_id] [ciphers] [SNI...] +// ├─────────────┤├──────────────────────────────────────────────────────────────────┤ +// +// TLS record Payload (248 bytes) +// header (5B) +// +// 16 = Handshake +// 03 01 = TLS 1.0 (record layer version) +// 00 F8 = 248 bytes follow +// +// 01 = ClientHello (handshake type) +// 00 00 F4 = 244 bytes of handshake body +// 03 03 = TLS 1.2 (actual protocol version) +// ...rest of ClientHello... +// +// Fragmented record look like: +// +// Record 1: +// +// 16 03 01 00 03 01 00 00 +// ├─────────────┤├──────┤ +// +// TLS header 3 bytes of payload +// +// 16 = Handshake +// 03 01 = TLS 1.0 +// 00 03 = only 3 bytes follow +// +// 01 = ClientHello type +// 00 00 = first 2 bytes of the uint24 length (INCOMPLETE!) +// +// Record 2: +// 16 03 01 00 F5 F4 03 03 [32 bytes random] [session_id] [ciphers] [SNI...] +// ├─────────────┤├────────────────────────────────────────────────────────────┤ +// +// TLS header remaining 245 bytes of payload +// +// 16 = Handshake +// 03 01 = TLS 1.0 +// 00 F5 = 245 bytes follow +// +// F4 = last byte of uint24 length (now complete: 00 00 F4 = 244) +// 03 03 = TLS 1.2 +// ...rest of ClientHello continues... +// +// So it means that there could be a series of handshake packets of different +// lengths. The goal of this function is to concatenate these fragments. +type fragmentedHandshakeReader struct { + r io.Reader + buf bytes.Buffer + readFragments int +} + +func (f *fragmentedHandshakeReader) Read(p []byte) (int, error) { + if n, err := f.buf.Read(p); err == nil { + return n, nil + } + + f.buf.Reset() + + for f.buf.Len() == 0 { + if f.readFragments > maxFragmentsCount { + return 0, ErrTooManyFragments + } + + if err := f.parseNextFragment(); err != nil { + return 0, err + } + + f.readFragments++ + } + + return f.buf.Read(p) +} + +func (f *fragmentedHandshakeReader) parseNextFragment() error { + // record_type(1) + version(2) + size(2) + // 16 - type is 0x16 (handshake record) + // 03 01 - protocol version is "3,1" (also known as TLS 1.0) + // 00 f8 - 0xF8 (248) bytes of handshake message follows + header := [1 + 2 + 2]byte{} + + if _, err := io.ReadFull(f.r, header[:]); err != nil { + return fmt.Errorf("cannot read record header: %w", err) + } + + if header[0] != tls.TypeHandshake { + return fmt.Errorf("unexpected record type %#x", header[0]) + } + + if header[1] != 3 || header[2] != 1 { + return fmt.Errorf("unexpected protocol version %#x %#x", header[1], header[2]) + } + + length := int64(binary.BigEndian.Uint16(header[3:])) + _, err := io.CopyN(&f.buf, f.r, length) + + return err +} + +func parseClientHello(r io.Reader) (*bytes.Buffer, *bytes.Buffer, error) { + r = &fragmentedHandshakeReader{r: r} + header := [1 + 3]byte{} + + if _, err := io.ReadFull(r, header[:]); err != nil { + return nil, nil, fmt.Errorf("cannot read handshake header: %w", err) + } + + if header[0] != TypeHandshakeClient { + return nil, nil, fmt.Errorf("incorrect handshake type: %#x", header[0]) + } + + // unfortunately there is not uint24 in golang, so we just reuse header + header[0] = 0 + length := int64(binary.BigEndian.Uint32(header[:])) + + clientHelloCopy := &bytes.Buffer{} + clientHelloCopy.Write([]byte{tls.TypeHandshake, 3, 1}) + binary.Write( //nolint: errcheck + clientHelloCopy, + binary.BigEndian, + // 1 for handshake type + // 3 for handshake length + uint16(1+3+length), + ) + clientHelloCopy.WriteByte(TypeHandshakeClient) + clientHelloCopy.Write(header[1:]) + + handshakeCopy := &bytes.Buffer{} + writer := io.MultiWriter(clientHelloCopy, handshakeCopy) + + _, err := io.CopyN(writer, r, length) + + return clientHelloCopy, handshakeCopy, err +} From 3a68ea5f2d7e98f514784ddb196c201b866379d7 Mon Sep 17 00:00:00 2001 From: 9seconds Date: Wed, 1 Apr 2026 17:05:30 +0200 Subject: [PATCH 10/10] Update goreleaser --- mise.lock | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/mise.lock b/mise.lock index 0e057b1..3f4b5ca 100644 --- a/mise.lock +++ b/mise.lock @@ -82,40 +82,40 @@ checksum = "sha256:4932cfca5e75bf60fe1c576edf459e5e809e6644664a068185d64b84af3fa url = "https://github.com/golangci/golangci-lint/releases/download/v2.11.4/golangci-lint-2.11.4-windows-amd64.zip" [[tools.goreleaser]] -version = "2.14.3" +version = "2.15.2" backend = "aqua:goreleaser/goreleaser" [tools.goreleaser."platforms.linux-arm64"] -checksum = "sha256:581a10e53c1176b3e81ee45cf531e02dbf899db0bc7b795669347df4276ce948" -url = "https://github.com/goreleaser/goreleaser/releases/download/v2.14.3/goreleaser_Linux_arm64.tar.gz" +checksum = "sha256:5db66761a98f6693161e49e1a95d28d2673a892ba60cb4a5e16736cafd41c4c9" +url = "https://github.com/goreleaser/goreleaser/releases/download/v2.15.2/goreleaser_Linux_arm64.tar.gz" provenance = "cosign" [tools.goreleaser."platforms.linux-arm64-musl"] -checksum = "sha256:581a10e53c1176b3e81ee45cf531e02dbf899db0bc7b795669347df4276ce948" -url = "https://github.com/goreleaser/goreleaser/releases/download/v2.14.3/goreleaser_Linux_arm64.tar.gz" +checksum = "sha256:5db66761a98f6693161e49e1a95d28d2673a892ba60cb4a5e16736cafd41c4c9" +url = "https://github.com/goreleaser/goreleaser/releases/download/v2.15.2/goreleaser_Linux_arm64.tar.gz" provenance = "cosign" [tools.goreleaser."platforms.linux-x64"] -checksum = "sha256:dc7faeeeb6da8bdfda788626263a4ae725892a8c7504b975c3234127d4a44579" -url = "https://github.com/goreleaser/goreleaser/releases/download/v2.14.3/goreleaser_Linux_x86_64.tar.gz" +checksum = "sha256:0ebdbf0353aba566b969dde746cc4e4806f96c27aa2f3971b229a9df7611fedc" +url = "https://github.com/goreleaser/goreleaser/releases/download/v2.15.2/goreleaser_Linux_x86_64.tar.gz" provenance = "cosign" [tools.goreleaser."platforms.linux-x64-musl"] -checksum = "sha256:dc7faeeeb6da8bdfda788626263a4ae725892a8c7504b975c3234127d4a44579" -url = "https://github.com/goreleaser/goreleaser/releases/download/v2.14.3/goreleaser_Linux_x86_64.tar.gz" +checksum = "sha256:0ebdbf0353aba566b969dde746cc4e4806f96c27aa2f3971b229a9df7611fedc" +url = "https://github.com/goreleaser/goreleaser/releases/download/v2.15.2/goreleaser_Linux_x86_64.tar.gz" provenance = "cosign" [tools.goreleaser."platforms.macos-arm64"] -checksum = "sha256:3507798489e107a78aff36b169de48148a335ac26eb3161608d905f3f3a957bd" -url = "https://github.com/goreleaser/goreleaser/releases/download/v2.14.3/goreleaser_Darwin_all.tar.gz" -provenance = "cosign" +checksum = "sha256:0e6bd67688ac949780bf1166813a91f89856898ef4c40d7d46c2c74ebaa4b9ee" +url = "https://github.com/goreleaser/goreleaser/releases/download/v2.15.2/goreleaser_Darwin_all.tar.gz" +provenance = "github-attestations" [tools.goreleaser."platforms.macos-x64"] -checksum = "sha256:3507798489e107a78aff36b169de48148a335ac26eb3161608d905f3f3a957bd" -url = "https://github.com/goreleaser/goreleaser/releases/download/v2.14.3/goreleaser_Darwin_all.tar.gz" +checksum = "sha256:0e6bd67688ac949780bf1166813a91f89856898ef4c40d7d46c2c74ebaa4b9ee" +url = "https://github.com/goreleaser/goreleaser/releases/download/v2.15.2/goreleaser_Darwin_all.tar.gz" provenance = "cosign" [tools.goreleaser."platforms.windows-x64"] -checksum = "sha256:3deea8ff471aa258a2d99f3e5302971d7028647ae8ddaf103257a8113e485a31" -url = "https://github.com/goreleaser/goreleaser/releases/download/v2.14.3/goreleaser_Windows_x86_64.zip" +checksum = "sha256:7459832946dbe122c144f8d7f87484d8572ca005b779310aa6bb03346e8de17a" +url = "https://github.com/goreleaser/goreleaser/releases/download/v2.15.2/goreleaser_Windows_x86_64.zip" provenance = "cosign"