From b00e2d89558def8bf71c9f7e56e59d63d7627999 Mon Sep 17 00:00:00 2001 From: Igor Artamonov <1994.aik@gmail.com> Date: Fri, 1 May 2026 10:14:58 +0300 Subject: [PATCH 1/5] connector: hex-decode mtproxy secret dcs.MTProxy expects raw secret bytes. Carrying them verbatim through a YAML string field is impossible: real secrets contain bytes >= 0x80 (faketls starts with 0xee, secured with 0xdd) which cannot survive a unicode string round-trip, so the value reached the bridge corrupted or empty (gotd then logged "invalid secret"). Accept the standard hex form printed by mtg/MTProxy tooling (e.g. "ee" + 16-byte secret + cloak domain hex) and decode it before handing the bytes to gotd. Co-Authored-By: Claude Opus 4.7 (1M context) --- pkg/connector/example-config.yaml | 2 ++ pkg/connector/proxy.go | 24 +++++++++++++++++++- pkg/connector/proxy_test.go | 37 +++++++++++++++++++++++++++++++ 3 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 pkg/connector/proxy_test.go diff --git a/pkg/connector/example-config.yaml b/pkg/connector/example-config.yaml index d7f23a29..a8cc0649 100644 --- a/pkg/connector/example-config.yaml +++ b/pkg/connector/example-config.yaml @@ -61,6 +61,8 @@ proxy: # Proxy IP address/domain name and port. address: "127.0.0.1:1080" # Proxy authentication (optional). Put MTProxy secret in password field. + # For mtproxy, the secret must be hex-encoded (the same form mtg/MTProxy + # tools print, e.g. "ee" + 16-byte secret + cloak domain hex for faketls). username: password: diff --git a/pkg/connector/proxy.go b/pkg/connector/proxy.go index 2ee361c5..3f6fde76 100644 --- a/pkg/connector/proxy.go +++ b/pkg/connector/proxy.go @@ -17,13 +17,31 @@ package connector import ( + "encoding/hex" "fmt" + "strings" "golang.org/x/net/proxy" "go.mau.fi/mautrix-telegram/pkg/gotd/telegram/dcs" ) +// decodeMTProxySecret parses an MTProxy secret string into raw bytes. +// MTProxy secrets are binary (faketls secrets begin with 0xEE, secured with 0xDD) +// and cannot be carried verbatim in a YAML string field, so we accept the standard +// hex encoding (optionally prefixed with "ee"/"dd") used by mtg/MTProxy tooling. +func decodeMTProxySecret(s string) ([]byte, error) { + s = strings.TrimSpace(s) + if s == "" { + return nil, fmt.Errorf("mtproxy secret is empty") + } + b, err := hex.DecodeString(s) + if err != nil { + return nil, fmt.Errorf("mtproxy secret must be hex-encoded: %w", err) + } + return b, nil +} + func GetProxyDialFunc(cfg ProxyConfig) (dcs.DialFunc, error) { switch cfg.Type { // we can't proxy HTTP through mtproxy @@ -54,7 +72,11 @@ func GetProxyResolver(cfg ProxyConfig) (dcs.Resolver, error) { resolver := dcs.Plain(dcs.PlainOptions{Dial: dialer}) return resolver, nil case "mtproxy": - return dcs.MTProxy(cfg.Address, []byte(cfg.Password), dcs.MTProxyOptions{}) + secret, err := decodeMTProxySecret(cfg.Password) + if err != nil { + return nil, err + } + return dcs.MTProxy(cfg.Address, secret, dcs.MTProxyOptions{}) default: return nil, fmt.Errorf("unsupported proxy type %s", cfg.Type) } diff --git a/pkg/connector/proxy_test.go b/pkg/connector/proxy_test.go new file mode 100644 index 00000000..d6f3113c --- /dev/null +++ b/pkg/connector/proxy_test.go @@ -0,0 +1,37 @@ +package connector + +import ( + "bytes" + "testing" +) + +func TestDecodeMTProxySecret(t *testing.T) { + // faketls secret: 0xee + 16 bytes + cloak domain ("working-name.ru" = 15 bytes) + hexSecret := "ee971746d927f4c0138b18447bfe1269bc70312e776f726b696e672d6e616d652e7275" + want := []byte{ + 0xee, + 0x97, 0x17, 0x46, 0xd9, 0x27, 0xf4, 0xc0, 0x13, + 0x8b, 0x18, 0x44, 0x7b, 0xfe, 0x12, 0x69, 0xbc, + 0x70, 0x31, 0x2e, 0x77, 0x6f, 0x72, 0x6b, 0x69, + 0x6e, 0x67, 0x2d, 0x6e, 0x61, 0x6d, 0x65, 0x2e, + 0x72, 0x75, + } + got, err := decodeMTProxySecret(hexSecret) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !bytes.Equal(got, want) { + t.Fatalf("decoded bytes mismatch:\n got=%x\nwant=%x", got, want) + } + + if _, err := decodeMTProxySecret(" " + hexSecret + "\n"); err != nil { + t.Fatalf("whitespace should be tolerated: %v", err) + } + + if _, err := decodeMTProxySecret(""); err == nil { + t.Fatal("expected error for empty secret") + } + if _, err := decodeMTProxySecret("not-hex!!"); err == nil { + t.Fatal("expected error for non-hex secret") + } +} -- 2.52.0 From aab48f0dbe219d0077aa5c0f5dea95e566dfb7f4 Mon Sep 17 00:00:00 2001 From: Igor Artamonov <1994.aik@gmail.com> Date: Fri, 1 May 2026 10:51:50 +0300 Subject: [PATCH 2/5] faketls: include real record type and peek bytes on handshake errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous error path used errors.Wrap(err, "unexpected record type") inside type-mismatch branches where err was already nil. With go-faster/errors that produced a wrapError with no cause and no detail, making the user-visible message "unexpected record type" useless for diagnostics — there was no way to tell what mtg actually sent. Switch to errors.Errorf with the actual received byte and a 32-byte hex peek of the read buffer. Also wrap the read-error path with the same peek so a partial response is visible. This is a diagnostic-only change; the parser semantics are unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- pkg/gotd/mtproxy/faketls/server_hello.go | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/pkg/gotd/mtproxy/faketls/server_hello.go b/pkg/gotd/mtproxy/faketls/server_hello.go index ff148dad..abda1f48 100644 --- a/pkg/gotd/mtproxy/faketls/server_hello.go +++ b/pkg/gotd/mtproxy/faketls/server_hello.go @@ -4,11 +4,20 @@ import ( "bytes" "crypto/hmac" "crypto/sha256" + "encoding/hex" "io" "github.com/go-faster/errors" ) +// peekDump returns up to n bytes from the start of buf as a hex string for diagnostics. +func peekDump(buf []byte, n int) string { + if len(buf) < n { + n = len(buf) + } + return hex.EncodeToString(buf[:n]) +} + // readServerHello reads faketls ServerHello. func readServerHello(r io.Reader, clientRandom [32]byte, secret []byte) error { packetBuf := bytes.NewBuffer(nil) @@ -16,10 +25,11 @@ func readServerHello(r io.Reader, clientRandom [32]byte, secret []byte) error { handshake, err := readRecord(r) if err != nil { - return errors.Wrap(err, "handshake record") + return errors.Wrapf(err, "handshake record (peek=%s)", peekDump(packetBuf.Bytes(), 32)) } if handshake.Type != RecordTypeHandshake { - return errors.Wrap(err, "unexpected record type") + return errors.Errorf("unexpected handshake record type: got 0x%02x, want 0x%02x (peek=%s)", + byte(handshake.Type), byte(RecordTypeHandshake), peekDump(packetBuf.Bytes(), 32)) } changeCipher, err := readRecord(r) @@ -27,7 +37,8 @@ func readServerHello(r io.Reader, clientRandom [32]byte, secret []byte) error { return errors.Wrap(err, "change cipher record") } if changeCipher.Type != RecordTypeChangeCipherSpec { - return errors.Wrap(err, "unexpected record type") + return errors.Errorf("unexpected change cipher record type: got 0x%02x, want 0x%02x", + byte(changeCipher.Type), byte(RecordTypeChangeCipherSpec)) } cert, err := readRecord(r) @@ -35,7 +46,8 @@ func readServerHello(r io.Reader, clientRandom [32]byte, secret []byte) error { return errors.Wrap(err, "cert record") } if cert.Type != RecordTypeApplication { - return errors.Wrap(err, "unexpected record type") + return errors.Errorf("unexpected application record type: got 0x%02x, want 0x%02x", + byte(cert.Type), byte(RecordTypeApplication)) } // `$record_header = type 1 byte + version 2 bytes + payload_length 2 bytes = 5 bytes` -- 2.52.0 From 64bf6bfe90ac9db1d56fb69a63541cbf3469e649 Mon Sep 17 00:00:00 2001 From: Igor Artamonov <1994.aik@gmail.com> Date: Fri, 1 May 2026 11:15:03 +0300 Subject: [PATCH 3/5] faketls: emit GREASE bytes and a real padding extension MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ClientHello builder used a closure G(n) that was supposed to insert two random GREASE bytes (RFC 8701, 0x?A?A pattern) at known positions but expanded the buffer by zero. Every grease slot was therefore omitted, and the trailing padding extension was written as a bare ext id 0x0015 followed by raw zeros — its length field was never set. Concretely, the old output looked structurally invalid to mtg's faketls validator: the cipher list was off by two, supported_groups declared a list_length larger than its body, and what should have been the padding extension parsed as a stream of empty server_name extensions. mtg responded with a fatal TLS Alert (description 50, decode_error) and shut the connection. Fix: - generate seven distinct GREASE bytes per ClientHello, with the tdlib constraint grease[3] != grease[4] - thread an io.Reader through writeClientHello so generation is deterministic in tests and keyed off the FakeTLS rand source in prod - replace the trailing zero-pad with a proper padding extension whose length field is computed so the ClientHello is exactly 517 bytes Add a regression test (structure_test.go) that feeds the result to crypto/tls.Server: it must not return decode_error / malformed / syntax errors. The previous output failed this; the new output passes. The TestTLS golden vector is regenerated for the new layout. Co-Authored-By: Claude Opus 4.7 (1M context) --- pkg/gotd/mtproxy/faketls/client_hello.go | 47 ++++++++-- pkg/gotd/mtproxy/faketls/faketls.go | 2 +- pkg/gotd/mtproxy/faketls/faketls_test.go | 75 ++++++++------- pkg/gotd/mtproxy/faketls/structure_test.go | 101 +++++++++++++++++++++ 4 files changed, 185 insertions(+), 40 deletions(-) create mode 100644 pkg/gotd/mtproxy/faketls/structure_test.go diff --git a/pkg/gotd/mtproxy/faketls/client_hello.go b/pkg/gotd/mtproxy/faketls/client_hello.go index 4efe5ba8..7d51489b 100644 --- a/pkg/gotd/mtproxy/faketls/client_hello.go +++ b/pkg/gotd/mtproxy/faketls/client_hello.go @@ -14,7 +14,26 @@ import ( const clientHelloLength = 517 -func createClientHello(b *bin.Buffer, sessionID [32]byte, domain string, key [32]byte) (randomOffset int) { +// generateGrease produces seven GREASE bytes following the TLS spec +// (RFC 8701) and tdlib's TlsInit.cpp constraints used by MTProxy faketls +// validators: each byte has the form 0x?A (low nibble 0x0A), and grease[3] +// must differ from grease[4]. +func generateGrease(rng io.Reader) ([7]byte, error) { + var raw [7]byte + if _, err := io.ReadFull(rng, raw[:]); err != nil { + return raw, errors.Wrap(err, "read grease entropy") + } + var g [7]byte + for i, r := range raw { + g[i] = (r & 0xF0) | 0x0A + } + if g[3] == g[4] { + g[3] ^= 0x10 + } + return g, nil +} + +func createClientHello(b *bin.Buffer, sessionID [32]byte, domain string, key [32]byte, grease [7]byte) (randomOffset int) { S := func(s string) { b.Buf = append(b.Buf, s...) } @@ -22,8 +41,9 @@ func createClientHello(b *bin.Buffer, sessionID [32]byte, domain string, key [32 randomOffset = len(b.Buf) b.Expand(n) } - G := func(_ int) { - b.Expand(0) + G := func(n int) { + v := grease[n] + b.Buf = append(b.Buf, v, v) } R := func() { b.Buf = append(b.Buf, sessionID[:]...) @@ -83,9 +103,18 @@ func createClientHello(b *bin.Buffer, sessionID [32]byte, domain string, key [32 G(3) S("\x00\x01\x00\x00\x15") - if pad := clientHelloLength - b.Len(); pad > 0 { - b.Expand(pad) + // Padding extension (id 0x0015 already written above): write its + // length so the resulting ClientHello is exactly clientHelloLength + // bytes, then fill the body with zeros. + padLen := clientHelloLength - b.Len() - 2 + if padLen < 0 { + padLen = 0 } + lenPos := b.Len() + b.Expand(2) + binary.BigEndian.PutUint16(b.Buf[lenPos:lenPos+2], uint16(padLen)) + b.Expand(padLen) + return randomOffset } @@ -94,15 +123,21 @@ func createClientHello(b *bin.Buffer, sessionID [32]byte, domain string, key [32 // See https://tools.ietf.org/html/rfc5246#section-7.4.1.1. func writeClientHello( w io.Writer, + rng io.Reader, now clock.Clock, sessionID [32]byte, domain string, secret []byte, ) (r [32]byte, err error) { + grease, err := generateGrease(rng) + if err != nil { + return [32]byte{}, err + } + b := &bin.Buffer{ Buf: make([]byte, 0, 576), } - randomOffset := createClientHello(b, sessionID, domain, [32]byte{}) + randomOffset := createClientHello(b, sessionID, domain, [32]byte{}, grease) // https://github.com/tdlib/td/blob/27d3fdd09d90f6b77ecbcce50b1e86dc4b3dd366/td/mtproto/TlsInit.cpp#L380-L384 mac := hmac.New(sha256.New, secret) diff --git a/pkg/gotd/mtproxy/faketls/faketls.go b/pkg/gotd/mtproxy/faketls/faketls.go index 2833f4de..feb78569 100644 --- a/pkg/gotd/mtproxy/faketls/faketls.go +++ b/pkg/gotd/mtproxy/faketls/faketls.go @@ -46,7 +46,7 @@ func (o *FakeTLS) Handshake(protocol [4]byte, dc int, s mtproxy.Secret) error { return errors.Wrap(err, "generate sessionID") } - clientDigest, err := writeClientHello(o.conn, o.clock, sessionID, s.CloakHost, s.Secret) + clientDigest, err := writeClientHello(o.conn, o.rand, o.clock, sessionID, s.CloakHost, s.Secret) if err != nil { return errors.Wrap(err, "send ClientHello") } diff --git a/pkg/gotd/mtproxy/faketls/faketls_test.go b/pkg/gotd/mtproxy/faketls/faketls_test.go index 5cee1e5a..9d55f639 100644 --- a/pkg/gotd/mtproxy/faketls/faketls_test.go +++ b/pkg/gotd/mtproxy/faketls/faketls_test.go @@ -10,6 +10,15 @@ import ( "github.com/gotd/neo" ) +type zeroReader struct{} + +func (zeroReader) Read(p []byte) (int, error) { + for i := range p { + p[i] = 0 + } + return len(p), nil +} + func TestTLS(t *testing.T) { a := require.New(t) secret := [32]byte{} @@ -17,42 +26,42 @@ func TestTLS(t *testing.T) { c := neo.NewTime(time.Date(2010, 10, 10, 1, 1, 1, 0, time.UTC)) b := bytes.NewBuffer(nil) - _, err := writeClientHello(b, c, sessionID, "google.com", secret[:]) + _, err := writeClientHello(b, zeroReader{}, c, sessionID, "google.com", secret[:]) a.NoError(err) testVector := []byte{ - 0x16, 0x03, 0x01, 0x02, 0x00, 0x01, 0x00, 0x01, 0xfc, 0x03, 0x03, 0xf9, 0x75, 0x5f, 0xdd, 0xb9, - 0xe3, 0x46, 0x57, 0x5a, 0x26, 0x71, 0xfa, 0x29, 0x7f, 0xab, 0xf0, 0xa1, 0xf3, 0x69, 0x4f, 0x72, - 0xe0, 0xc3, 0x8f, 0x62, 0x77, 0x5c, 0x8f, 0x5a, 0xf8, 0xa2, 0xa9, 0x20, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 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, 0x01, 0x93, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x0f, 0x00, 0x0d, 0x00, 0x00, 0x0a, 0x67, 0x6f, 0x6f, 0x67, 0x6c, - 0x65, 0x2e, 0x63, 0x6f, 0x6d, 0x00, 0x17, 0x00, 0x00, 0xff, 0x01, 0x00, 0x01, 0x00, 0x00, 0x0a, - 0x00, 0x0a, 0x00, 0x08, 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, 0x00, 0x33, 0x00, 0x2b, 0x00, 0x29, 0x00, - 0x01, 0x00, 0x00, 0x1d, 0x00, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x2d, 0x00, 0x02, 0x01, 0x01, 0x00, 0x2b, 0x00, 0x0b, - 0x0a, 0x03, 0x04, 0x03, 0x03, 0x03, 0x02, 0x03, 0x01, 0x00, 0x1b, 0x00, 0x03, 0x02, 0x00, 0x02, - 0x00, 0x01, 0x00, 0x00, 0x15, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x16, 0x03, 0x01, 0x02, 0x00, 0x01, 0x00, 0x01, 0xfc, 0x03, 0x03, 0xa9, 0xa8, 0x7f, 0x37, 0x9b, + 0x09, 0x80, 0x6a, 0xf3, 0xff, 0x78, 0x4a, 0x6c, 0x4e, 0xbd, 0xdd, 0x94, 0x31, 0x8e, 0x7c, 0x09, + 0x36, 0x63, 0x77, 0x1d, 0x36, 0xf4, 0xcb, 0x6d, 0x3e, 0x13, 0x83, 0x20, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x0a, 0x0a, + 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, + 0x01, 0x93, 0x0a, 0x0a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0f, 0x00, 0x0d, 0x00, 0x00, 0x0a, 0x67, + 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x63, 0x6f, 0x6d, 0x00, 0x17, 0x00, 0x00, 0xff, 0x01, 0x00, + 0x01, 0x00, 0x00, 0x0a, 0x00, 0x0a, 0x00, 0x08, 0x0a, 0x0a, 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, 0x00, + 0x33, 0x00, 0x2b, 0x00, 0x29, 0x0a, 0x0a, 0x00, 0x01, 0x00, 0x00, 0x1d, 0x00, 0x20, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x2d, + 0x00, 0x02, 0x01, 0x01, 0x00, 0x2b, 0x00, 0x0b, 0x0a, 0x0a, 0x0a, 0x03, 0x04, 0x03, 0x03, 0x03, + 0x02, 0x03, 0x01, 0x00, 0x1b, 0x00, 0x03, 0x02, 0x00, 0x02, 0x1a, 0x1a, 0x00, 0x01, 0x00, 0x00, + 0x15, 0x00, 0xd2, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, } a.Equal(testVector, b.Bytes()) diff --git a/pkg/gotd/mtproxy/faketls/structure_test.go b/pkg/gotd/mtproxy/faketls/structure_test.go new file mode 100644 index 00000000..7f0e643f --- /dev/null +++ b/pkg/gotd/mtproxy/faketls/structure_test.go @@ -0,0 +1,101 @@ +package faketls + +import ( + "crypto/tls" + "net" + "strings" + "testing" + "time" + + "go.mau.fi/mautrix-telegram/pkg/gotd/bin" +) + +// TestClientHelloStructure verifies that what we generate is a syntactically +// valid TLS ClientHello — i.e., the Go crypto/tls server can parse it without +// returning a "decode_error"-like syntax error. We don't care that the TLS +// handshake then fails (it will, since we're using a fake cert / random data); +// we only care that parsing succeeds. +func TestClientHelloStructure(t *testing.T) { + // Render with deterministic rng + sessionID + key. + var session [32]byte + for i := range session { + session[i] = byte(i) + } + var key [32]byte + for i := range key { + key[i] = 0xAA + } + var grease [7]byte + for i := range grease { + grease[i] = byte(0x0A + i*0x10) + } + if grease[3] == grease[4] { + grease[3] ^= 0x10 + } + + b := &bin.Buffer{Buf: make([]byte, 0, 576)} + createClientHello(b, session, "example.com", key, grease) + if len(b.Buf) != clientHelloLength { + t.Fatalf("expected %d bytes, got %d", clientHelloLength, len(b.Buf)) + } + + // Wire it through a real TLS server. The server reads bytes from + // our pipe; if it accepts ClientHello but fails on cert/MAC, we get + // a non-syntax error. If it returns "decode_error", we know we're + // still busted. + clientConn, serverConn := net.Pipe() + defer clientConn.Close() + defer serverConn.Close() + + go func() { + clientConn.Write(b.Buf) + // keep the pipe open until done + }() + + cfg := &tls.Config{ + Certificates: []tls.Certificate{generateSelfSigned(t)}, + } + srv := tls.Server(serverConn, cfg) + srv.SetDeadline(time.Now().Add(2 * time.Second)) + err := srv.Handshake() + if err == nil { + return // unexpectedly succeeded — fine for our purpose + } + t.Logf("server handshake error (expected non-syntax): %v", err) + + msg := err.Error() + for _, marker := range []string{"decode_error", "syntax", "malformed", "bad ClientHello"} { + if strings.Contains(msg, marker) { + t.Fatalf("structural parse failure (%q) — ClientHello is malformed: %v", marker, err) + } + } +} + +// generateSelfSigned builds a throwaway cert for the test TLS server. +func generateSelfSigned(t *testing.T) tls.Certificate { + cert, err := tls.X509KeyPair(testCertPEM, testKeyPEM) + if err != nil { + t.Fatal(err) + } + return cert +} + +// Generated with `go run filippo.io/mkcert@latest -ecdsa example.com`-ish. +// Embedded here for deterministic test environment. +var testCertPEM = []byte(`-----BEGIN CERTIFICATE----- +MIIBhTCCASugAwIBAgIQIRi6zePL6mKjOipn+dNuaTAKBggqhkjOPQQDAjASMRAw +DgYDVQQKEwdBY21lIENvMB4XDTE3MTAyMDE5NDMwNloXDTE4MTAyMDE5NDMwNlow +EjEQMA4GA1UEChMHQWNtZSBDbzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABD0d +7VNhbWvZLWPuj/RtHFjvtJBEwOkhbN/BnnE8rnZR8+sbwnc/KhCk3FhnpHZnQz7B +5aETbbIgmuvewdjvSBSjYzBhMA4GA1UdDwEB/wQEAwICpDATBgNVHSUEDDAKBggr +BgEFBQcDATAPBgNVHRMBAf8EBTADAQH/MCkGA1UdEQQiMCCCDmxvY2FsaG9zdDo1 +NDUzgg4xMjcuMC4wLjE6NTQ1MzAKBggqhkjOPQQDAgNIADBFAiEA2zpJEPQyz6/l +Wf86aX6PepsntZv2GYlA5UpabfT2EZICICpJ5h/iI+i341gBmLiAFQOyTDT+/wQc +6MF9+Yw1Yy0t +-----END CERTIFICATE-----`) + +var testKeyPEM = []byte(`-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIIrYSSNQFaA2Hwf1duRSxKtLYX5CB04fSeQ6tF1aY/PuoAoGCCqGSM49 +AwEHoUQDQgAEPR3tU2Fta9ktY+6P9G0cWO+0kETA6SFs38GecTyudlHz6xvCdz8q +EKTcWGekdmdDPsHloRNtsiCa697B2O9IFA== +-----END EC PRIVATE KEY-----`) -- 2.52.0 From 4768065e7218302e2090dcf668c28c4f34ee851f Mon Sep 17 00:00:00 2001 From: Igor Artamonov <1994.aik@gmail.com> Date: Fri, 1 May 2026 12:17:27 +0300 Subject: [PATCH 4/5] faketls: skip ChangeCipherSpec records on read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Read path treated every TLS record's payload as application data and wrote it into readBuf — including the 1-byte payload (0x01) of ChangeCipherSpec records. mtg sends those records intermittently as a TLS-compat keep-alive; once one arrived inside the data stream it desynced the obfuscated2 CTR keystream by one byte. From that point on every MTProto message decrypted to garbage and the engine failed with "decrypt: msg_key is invalid", forcibly closed the connection, and looped. The Go switch cases for ChangeCipherSpec and Application were both empty (no fallthrough, no continue), so control reached the o.readBuf.Write(rec.Data) call below the switch for both — exactly the wrong behaviour for CCS. Reshape the loop so that: - ChangeCipherSpec records are silently dropped - Application records are written to readBuf and returned - Handshake / unsupported types still error out This matches tdlib's TlsTransport (CCS is skipped at the TLS framing layer and never reaches the MTProto decoder). Co-Authored-By: Claude Opus 4.7 (1M context) --- pkg/gotd/mtproxy/faketls/ccs_test.go | 65 ++++++++++++++++++++++++++++ pkg/gotd/mtproxy/faketls/faketls.go | 35 +++++++++------ 2 files changed, 86 insertions(+), 14 deletions(-) create mode 100644 pkg/gotd/mtproxy/faketls/ccs_test.go diff --git a/pkg/gotd/mtproxy/faketls/ccs_test.go b/pkg/gotd/mtproxy/faketls/ccs_test.go new file mode 100644 index 00000000..07b7e990 --- /dev/null +++ b/pkg/gotd/mtproxy/faketls/ccs_test.go @@ -0,0 +1,65 @@ +package faketls + +import ( + "bytes" + "io" + "testing" + + "github.com/stretchr/testify/require" +) + +// TestReadSkipsChangeCipherSpec ensures interleaved ChangeCipherSpec records +// do not pollute the Application-data stream. Earlier behaviour wrote the +// 1-byte CCS payload into readBuf, which desynced the obfuscated2 CTR +// keystream and produced "msg_key is invalid" on decrypted MTProto messages. +func TestReadSkipsChangeCipherSpec(t *testing.T) { + a := require.New(t) + + wire := bytes.NewBuffer(nil) + + // CCS record (1 byte = 0x01) + _, err := writeRecord(wire, record{ + Type: RecordTypeChangeCipherSpec, + Version: Version12Bytes, + Data: []byte{0x01}, + }) + a.NoError(err) + + // Application record carrying our payload + payload := []byte("hello-mtproto-bytes") + _, err = writeRecord(wire, record{ + Type: RecordTypeApplication, + Version: Version12Bytes, + Data: payload, + }) + a.NoError(err) + + // Another CCS in the middle + _, err = writeRecord(wire, record{ + Type: RecordTypeChangeCipherSpec, + Version: Version12Bytes, + Data: []byte{0x01}, + }) + a.NoError(err) + + // Second application record + more := []byte("second-payload") + _, err = writeRecord(wire, record{ + Type: RecordTypeApplication, + Version: Version12Bytes, + Data: more, + }) + a.NoError(err) + + tls := NewFakeTLS(zeroReader{}, &readonly{r: wire}) + + got, err := io.ReadAll(io.LimitReader(tls, int64(len(payload)+len(more)))) + a.NoError(err) + a.Equal(append(append([]byte(nil), payload...), more...), got) +} + +// readonly adapts an io.Reader to io.ReadWriter (NewFakeTLS demands one). +type readonly struct{ r io.Reader } + +func (r *readonly) Read(p []byte) (int, error) { return r.r.Read(p) } +func (r *readonly) Write(p []byte) (int, error) { return len(p), nil } diff --git a/pkg/gotd/mtproxy/faketls/faketls.go b/pkg/gotd/mtproxy/faketls/faketls.go index feb78569..d8bd5c59 100644 --- a/pkg/gotd/mtproxy/faketls/faketls.go +++ b/pkg/gotd/mtproxy/faketls/faketls.go @@ -93,20 +93,27 @@ func (o *FakeTLS) Read(b []byte) (n int, err error) { return o.readBuf.Read(b) } - rec, err := readRecord(o.conn) - if err != nil { - return 0, errors.Wrap(err, "read TLS record") - } + // Skip ChangeCipherSpec records — they are TLS-level keep-alive / + // compatibility markers (one-byte payload 0x01) and must NOT be + // passed up to the obfuscated2 layer, otherwise the CTR keystream + // position desyncs and subsequent MTProto messages decrypt to + // garbage (`msg_key is invalid`). + for { + rec, err := readRecord(o.conn) + if err != nil { + return 0, errors.Wrap(err, "read TLS record") + } - switch rec.Type { - case RecordTypeChangeCipherSpec: - case RecordTypeApplication: - case RecordTypeHandshake: - return 0, errors.New("unexpected record type handshake") - default: - return 0, errors.Errorf("unsupported record type %v", rec.Type) + switch rec.Type { + case RecordTypeChangeCipherSpec: + continue + case RecordTypeApplication: + o.readBuf.Write(rec.Data) + return o.readBuf.Read(b) + case RecordTypeHandshake: + return 0, errors.New("unexpected record type handshake") + default: + return 0, errors.Errorf("unsupported record type %v", rec.Type) + } } - o.readBuf.Write(rec.Data) - - return o.readBuf.Read(b) } -- 2.52.0 From ccb349f3d20907b259df3cbc9355346ce1205021 Mon Sep 17 00:00:00 2001 From: Igor Artamonov <1994.aik@gmail.com> Date: Fri, 1 May 2026 12:33:44 +0300 Subject: [PATCH 5/5] obfuscated2: only XOR bytes actually delivered on Read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read called XORKeyStream(b, b) — XOR-ing the entire caller buffer even when the underlying transport returned fewer bytes. AES-CTR's keystream position is then advanced by len(b), but the peer only consumed n bytes' worth of keystream. After a single short read the two keystreams diverge for the lifetime of the connection, every subsequent MTProto message decrypts to garbage, and the engine fails with "consume message: decrypt: msg_key is invalid". The faketls layer makes short reads routine: each Read returns at most one TLS Application record's payload, regardless of how big the caller buffer is. So in practice the stream desynced almost immediately on high-traffic clients (active supergroups, post-relogin catch-up) and intermittently on quiet ones. Match the upstream gotd/td fix and only XOR the n bytes that came out of the transport. Add a regression test (chunkConn delivers ciphertext in 7-byte chunks; client reads through Obfuscated2.Read with a 128-byte buffer; plaintext must round-trip). Co-Authored-By: Claude Opus 4.7 (1M context) --- pkg/gotd/mtproxy/obfuscated2/obfuscated2.go | 7 +- .../mtproxy/obfuscated2/short_read_test.go | 82 +++++++++++++++++++ 2 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 pkg/gotd/mtproxy/obfuscated2/short_read_test.go diff --git a/pkg/gotd/mtproxy/obfuscated2/obfuscated2.go b/pkg/gotd/mtproxy/obfuscated2/obfuscated2.go index fe642d1f..7aecae38 100644 --- a/pkg/gotd/mtproxy/obfuscated2/obfuscated2.go +++ b/pkg/gotd/mtproxy/obfuscated2/obfuscated2.go @@ -53,7 +53,12 @@ func (o *Obfuscated2) Read(b []byte) (int, error) { return n, err } if n > 0 { - o.decrypt.XORKeyStream(b, b) + // IMPORTANT: only XOR the n bytes that were actually read. + // XOR-ing the full b advances the CTR keystream past where the + // server is and permanently desyncs the stream — every later + // MTProto message decrypts to garbage and the engine fails + // with "msg_key is invalid". + o.decrypt.XORKeyStream(b[:n], b[:n]) } return n, err } diff --git a/pkg/gotd/mtproxy/obfuscated2/short_read_test.go b/pkg/gotd/mtproxy/obfuscated2/short_read_test.go new file mode 100644 index 00000000..bb2b32c8 --- /dev/null +++ b/pkg/gotd/mtproxy/obfuscated2/short_read_test.go @@ -0,0 +1,82 @@ +package obfuscated2 + +import ( + "bytes" + "crypto/aes" + "crypto/cipher" + "io" + "testing" + + "github.com/stretchr/testify/require" +) + +// chunkConn delivers data from buf in chunks of at most chunkSize bytes. +type chunkConn struct { + buf *bytes.Buffer + chunkSize int +} + +func (c *chunkConn) Read(p []byte) (int, error) { + if c.buf.Len() == 0 { + return 0, io.EOF + } + want := len(p) + if want > c.chunkSize { + want = c.chunkSize + } + return c.buf.Read(p[:want]) +} + +func (c *chunkConn) Write(p []byte) (int, error) { return len(p), nil } + +// TestShortReadKeepsKeystreamAligned ensures that when the underlying +// transport returns fewer bytes than the caller asked for, the CTR +// keystream is only advanced by the bytes actually delivered. +// +// The previous implementation called XORKeyStream(b, b) instead of +// XORKeyStream(b[:n], b[:n]); after a single short read the client and +// server keystreams diverged and every subsequent MTProto message +// failed integrity (msg_key invalid). +func TestShortReadKeepsKeystreamAligned(t *testing.T) { + a := require.New(t) + + key := bytes.Repeat([]byte{0x11}, 32) + iv := bytes.Repeat([]byte{0x22}, 16) + + enc, err := aes.NewCipher(key) + a.NoError(err) + dec, err := aes.NewCipher(key) + a.NoError(err) + + encStream := cipher.NewCTR(enc, iv) + decStream := cipher.NewCTR(dec, iv) + + plaintext := bytes.Repeat([]byte("Hello, MTProxy! "), 50) + ciphertext := make([]byte, len(plaintext)) + encStream.XORKeyStream(ciphertext, plaintext) + + wire := &chunkConn{buf: bytes.NewBuffer(append([]byte(nil), ciphertext...)), chunkSize: 7} + o := &Obfuscated2{ + conn: wire, + keys: keys{decrypt: decStream}, + } + + got := make([]byte, len(plaintext)) + off := 0 + for off < len(plaintext) { + end := off + 128 + if end > len(got) { + end = len(got) + } + n, err := o.Read(got[off:end]) + if err != nil && err != io.EOF { + t.Fatalf("read at off %d: %v", off, err) + } + if n == 0 { + t.Fatalf("zero-length read at off %d", off) + } + off += n + } + + a.Equal(plaintext, got, "short reads must not desync the keystream") +} -- 2.52.0