Compare commits

...

5 Commits

Author SHA1 Message Date
Igor Artamonov ccb349f3d2 obfuscated2: only XOR bytes actually delivered on Read
Go / Lint (old) (push) Failing after 4m40s
Go / Lint (latest) (push) Failing after 4m39s
Go / Lint (old) (pull_request) Failing after 4m41s
Go / Lint (latest) (pull_request) Failing after 4m40s
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) <noreply@anthropic.com>
2026-05-01 12:33:44 +03:00
Igor Artamonov 4768065e72 faketls: skip ChangeCipherSpec records on read
Go / Lint (old) (push) Failing after 4m40s
Go / Lint (latest) (push) Failing after 4m40s
Go / Lint (old) (pull_request) Failing after 4m42s
Go / Lint (latest) (pull_request) Failing after 4m39s
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) <noreply@anthropic.com>
2026-05-01 12:17:27 +03:00
Igor Artamonov 64bf6bfe90 faketls: emit GREASE bytes and a real padding extension
Go / Lint (old) (push) Failing after 4m39s
Go / Lint (latest) (push) Failing after 4m39s
Go / Lint (old) (pull_request) Failing after 4m39s
Go / Lint (latest) (pull_request) Failing after 4m42s
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) <noreply@anthropic.com>
2026-05-01 11:15:03 +03:00
Igor Artamonov aab48f0dbe faketls: include real record type and peek bytes on handshake errors
Go / Lint (old) (push) Failing after 4m43s
Go / Lint (latest) (push) Failing after 4m40s
Go / Lint (old) (pull_request) Failing after 4m40s
Go / Lint (latest) (pull_request) Failing after 4m43s
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) <noreply@anthropic.com>
2026-05-01 10:51:50 +03:00
Igor Artamonov b00e2d8955 connector: hex-decode mtproxy secret
Go / Lint (old) (push) Failing after 5m14s
Go / Lint (latest) (push) Failing after 5m19s
Go / Lint (old) (pull_request) Failing after 5m14s
Go / Lint (latest) (pull_request) Failing after 4m40s
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) <noreply@anthropic.com>
2026-05-01 10:14:58 +03:00
11 changed files with 437 additions and 60 deletions
+2
View File
@@ -61,6 +61,8 @@ proxy:
# Proxy IP address/domain name and port. # Proxy IP address/domain name and port.
address: "127.0.0.1:1080" address: "127.0.0.1:1080"
# Proxy authentication (optional). Put MTProxy secret in password field. # 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: username:
password: password:
+23 -1
View File
@@ -17,13 +17,31 @@
package connector package connector
import ( import (
"encoding/hex"
"fmt" "fmt"
"strings"
"golang.org/x/net/proxy" "golang.org/x/net/proxy"
"go.mau.fi/mautrix-telegram/pkg/gotd/telegram/dcs" "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) { func GetProxyDialFunc(cfg ProxyConfig) (dcs.DialFunc, error) {
switch cfg.Type { switch cfg.Type {
// we can't proxy HTTP through mtproxy // 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}) resolver := dcs.Plain(dcs.PlainOptions{Dial: dialer})
return resolver, nil return resolver, nil
case "mtproxy": 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: default:
return nil, fmt.Errorf("unsupported proxy type %s", cfg.Type) return nil, fmt.Errorf("unsupported proxy type %s", cfg.Type)
} }
+37
View File
@@ -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")
}
}
+65
View File
@@ -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 }
+41 -6
View File
@@ -14,7 +14,26 @@ import (
const clientHelloLength = 517 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) { S := func(s string) {
b.Buf = append(b.Buf, s...) 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) randomOffset = len(b.Buf)
b.Expand(n) b.Expand(n)
} }
G := func(_ int) { G := func(n int) {
b.Expand(0) v := grease[n]
b.Buf = append(b.Buf, v, v)
} }
R := func() { R := func() {
b.Buf = append(b.Buf, sessionID[:]...) b.Buf = append(b.Buf, sessionID[:]...)
@@ -83,9 +103,18 @@ func createClientHello(b *bin.Buffer, sessionID [32]byte, domain string, key [32
G(3) G(3)
S("\x00\x01\x00\x00\x15") S("\x00\x01\x00\x00\x15")
if pad := clientHelloLength - b.Len(); pad > 0 { // Padding extension (id 0x0015 already written above): write its
b.Expand(pad) // 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 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. // See https://tools.ietf.org/html/rfc5246#section-7.4.1.1.
func writeClientHello( func writeClientHello(
w io.Writer, w io.Writer,
rng io.Reader,
now clock.Clock, now clock.Clock,
sessionID [32]byte, sessionID [32]byte,
domain string, domain string,
secret []byte, secret []byte,
) (r [32]byte, err error) { ) (r [32]byte, err error) {
grease, err := generateGrease(rng)
if err != nil {
return [32]byte{}, err
}
b := &bin.Buffer{ b := &bin.Buffer{
Buf: make([]byte, 0, 576), 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 // https://github.com/tdlib/td/blob/27d3fdd09d90f6b77ecbcce50b1e86dc4b3dd366/td/mtproto/TlsInit.cpp#L380-L384
mac := hmac.New(sha256.New, secret) mac := hmac.New(sha256.New, secret)
+11 -4
View File
@@ -46,7 +46,7 @@ func (o *FakeTLS) Handshake(protocol [4]byte, dc int, s mtproxy.Secret) error {
return errors.Wrap(err, "generate sessionID") 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 { if err != nil {
return errors.Wrap(err, "send ClientHello") return errors.Wrap(err, "send ClientHello")
} }
@@ -93,6 +93,12 @@ func (o *FakeTLS) Read(b []byte) (n int, err error) {
return o.readBuf.Read(b) return o.readBuf.Read(b)
} }
// 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) rec, err := readRecord(o.conn)
if err != nil { if err != nil {
return 0, errors.Wrap(err, "read TLS record") return 0, errors.Wrap(err, "read TLS record")
@@ -100,13 +106,14 @@ func (o *FakeTLS) Read(b []byte) (n int, err error) {
switch rec.Type { switch rec.Type {
case RecordTypeChangeCipherSpec: case RecordTypeChangeCipherSpec:
continue
case RecordTypeApplication: case RecordTypeApplication:
o.readBuf.Write(rec.Data)
return o.readBuf.Read(b)
case RecordTypeHandshake: case RecordTypeHandshake:
return 0, errors.New("unexpected record type handshake") return 0, errors.New("unexpected record type handshake")
default: default:
return 0, errors.Errorf("unsupported record type %v", rec.Type) return 0, errors.Errorf("unsupported record type %v", rec.Type)
} }
o.readBuf.Write(rec.Data) }
return o.readBuf.Read(b)
} }
+28 -19
View File
@@ -10,6 +10,15 @@ import (
"github.com/gotd/neo" "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) { func TestTLS(t *testing.T) {
a := require.New(t) a := require.New(t)
secret := [32]byte{} secret := [32]byte{}
@@ -17,30 +26,30 @@ func TestTLS(t *testing.T) {
c := neo.NewTime(time.Date(2010, 10, 10, 1, 1, 1, 0, time.UTC)) c := neo.NewTime(time.Date(2010, 10, 10, 1, 1, 1, 0, time.UTC))
b := bytes.NewBuffer(nil) b := bytes.NewBuffer(nil)
_, err := writeClientHello(b, c, sessionID, "google.com", secret[:]) _, err := writeClientHello(b, zeroReader{}, c, sessionID, "google.com", secret[:])
a.NoError(err) a.NoError(err)
testVector := []byte{ testVector := []byte{
0x16, 0x03, 0x01, 0x02, 0x00, 0x01, 0x00, 0x01, 0xfc, 0x03, 0x03, 0xf9, 0x75, 0x5f, 0xdd, 0xb9, 0x16, 0x03, 0x01, 0x02, 0x00, 0x01, 0x00, 0x01, 0xfc, 0x03, 0x03, 0xa9, 0xa8, 0x7f, 0x37, 0x9b,
0xe3, 0x46, 0x57, 0x5a, 0x26, 0x71, 0xfa, 0x29, 0x7f, 0xab, 0xf0, 0xa1, 0xf3, 0x69, 0x4f, 0x72, 0x09, 0x80, 0x6a, 0xf3, 0xff, 0x78, 0x4a, 0x6c, 0x4e, 0xbd, 0xdd, 0x94, 0x31, 0x8e, 0x7c, 0x09,
0xe0, 0xc3, 0x8f, 0x62, 0x77, 0x5c, 0x8f, 0x5a, 0xf8, 0xa2, 0xa9, 0x20, 0x00, 0x00, 0x00, 0x00, 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, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x13, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x0a, 0x0a,
0x13, 0x02, 0x13, 0x03, 0xc0, 0x2b, 0xc0, 0x2f, 0xc0, 0x2c, 0xc0, 0x30, 0xcc, 0xa9, 0xcc, 0xa8, 0x13, 0x01, 0x13, 0x02, 0x13, 0x03, 0xc0, 0x2b, 0xc0, 0x2f, 0xc0, 0x2c, 0xc0, 0x30, 0xcc, 0xa9,
0xc0, 0x13, 0xc0, 0x14, 0x00, 0x9c, 0x00, 0x9d, 0x00, 0x2f, 0x00, 0x35, 0x01, 0x00, 0x01, 0x93, 0xcc, 0xa8, 0xc0, 0x13, 0xc0, 0x14, 0x00, 0x9c, 0x00, 0x9d, 0x00, 0x2f, 0x00, 0x35, 0x01, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x0f, 0x00, 0x0d, 0x00, 0x00, 0x0a, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x01, 0x93, 0x0a, 0x0a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0f, 0x00, 0x0d, 0x00, 0x00, 0x0a, 0x67,
0x65, 0x2e, 0x63, 0x6f, 0x6d, 0x00, 0x17, 0x00, 0x00, 0xff, 0x01, 0x00, 0x01, 0x00, 0x00, 0x0a, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x63, 0x6f, 0x6d, 0x00, 0x17, 0x00, 0x00, 0xff, 0x01, 0x00,
0x00, 0x0a, 0x00, 0x08, 0x00, 0x1d, 0x00, 0x17, 0x00, 0x18, 0x00, 0x0b, 0x00, 0x02, 0x01, 0x00, 0x01, 0x00, 0x00, 0x0a, 0x00, 0x0a, 0x00, 0x08, 0x0a, 0x0a, 0x00, 0x1d, 0x00, 0x17, 0x00, 0x18,
0x00, 0x23, 0x00, 0x00, 0x00, 0x10, 0x00, 0x0e, 0x00, 0x0c, 0x02, 0x68, 0x32, 0x08, 0x68, 0x74, 0x00, 0x0b, 0x00, 0x02, 0x01, 0x00, 0x00, 0x23, 0x00, 0x00, 0x00, 0x10, 0x00, 0x0e, 0x00, 0x0c,
0x74, 0x70, 0x2f, 0x31, 0x2e, 0x31, 0x00, 0x05, 0x00, 0x05, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x68, 0x32, 0x08, 0x68, 0x74, 0x74, 0x70, 0x2f, 0x31, 0x2e, 0x31, 0x00, 0x05, 0x00, 0x05,
0x0d, 0x00, 0x12, 0x00, 0x10, 0x04, 0x03, 0x08, 0x04, 0x04, 0x01, 0x05, 0x03, 0x08, 0x05, 0x05, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0d, 0x00, 0x12, 0x00, 0x10, 0x04, 0x03, 0x08, 0x04, 0x04,
0x01, 0x08, 0x06, 0x06, 0x01, 0x00, 0x12, 0x00, 0x00, 0x00, 0x33, 0x00, 0x2b, 0x00, 0x29, 0x00, 0x01, 0x05, 0x03, 0x08, 0x05, 0x05, 0x01, 0x08, 0x06, 0x06, 0x01, 0x00, 0x12, 0x00, 0x00, 0x00,
0x01, 0x00, 0x00, 0x1d, 0x00, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 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, 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, 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,
+16 -4
View File
@@ -4,11 +4,20 @@ import (
"bytes" "bytes"
"crypto/hmac" "crypto/hmac"
"crypto/sha256" "crypto/sha256"
"encoding/hex"
"io" "io"
"github.com/go-faster/errors" "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. // readServerHello reads faketls ServerHello.
func readServerHello(r io.Reader, clientRandom [32]byte, secret []byte) error { func readServerHello(r io.Reader, clientRandom [32]byte, secret []byte) error {
packetBuf := bytes.NewBuffer(nil) packetBuf := bytes.NewBuffer(nil)
@@ -16,10 +25,11 @@ func readServerHello(r io.Reader, clientRandom [32]byte, secret []byte) error {
handshake, err := readRecord(r) handshake, err := readRecord(r)
if err != nil { 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 { 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) 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") return errors.Wrap(err, "change cipher record")
} }
if changeCipher.Type != RecordTypeChangeCipherSpec { 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) 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") return errors.Wrap(err, "cert record")
} }
if cert.Type != RecordTypeApplication { 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` // `$record_header = type 1 byte + version 2 bytes + payload_length 2 bytes = 5 bytes`
+101
View File
@@ -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-----`)
+6 -1
View File
@@ -53,7 +53,12 @@ func (o *Obfuscated2) Read(b []byte) (int, error) {
return n, err return n, err
} }
if n > 0 { 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 return n, err
} }
@@ -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")
}