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>
This commit is contained in:
Igor Artamonov
2026-05-01 12:17:27 +03:00
parent 64bf6bfe90
commit 4768065e72
2 changed files with 86 additions and 14 deletions
+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 }
+21 -14
View File
@@ -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)
}