From 59557059df8c5746965c314c2c8aa5ecfb3a1c27 Mon Sep 17 00:00:00 2001 From: 9seconds Date: Wed, 11 Mar 2026 20:15:01 +0100 Subject: [PATCH] ReadClientHello function --- .mise.toml | 2 +- mtglib/internal/tls/fake/client_side.go | 305 ++++++++++++++ .../tls/fake/client_side_fuzz_test.go | 49 +++ .../tls/fake/client_side_snapshot_test.go | 143 +++++++ mtglib/internal/tls/fake/client_side_test.go | 395 ++++++++++++++++++ mtglib/internal/tls/fake/init.go | 16 + .../client-hello-bad-fa2e46cdb33e2a1b.json | 8 + .../client-hello-ok-19dfe38384b9884b.json | 8 + .../client-hello-ok-48f8a72a56f3174a.json | 8 + .../client-hello-ok-651054256093c6cd.json | 8 + .../client-hello-ok-79d01ef18a9d2621.json | 8 + .../client-hello-ok-7a5569f05b118145.json | 8 + 12 files changed, 957 insertions(+), 1 deletion(-) create mode 100644 mtglib/internal/tls/fake/client_side.go create mode 100644 mtglib/internal/tls/fake/client_side_fuzz_test.go create mode 100644 mtglib/internal/tls/fake/client_side_snapshot_test.go create mode 100644 mtglib/internal/tls/fake/client_side_test.go create mode 100644 mtglib/internal/tls/fake/init.go create mode 100644 mtglib/internal/tls/fake/testdata/client-hello-bad-fa2e46cdb33e2a1b.json create mode 100644 mtglib/internal/tls/fake/testdata/client-hello-ok-19dfe38384b9884b.json create mode 100644 mtglib/internal/tls/fake/testdata/client-hello-ok-48f8a72a56f3174a.json create mode 100644 mtglib/internal/tls/fake/testdata/client-hello-ok-651054256093c6cd.json create mode 100644 mtglib/internal/tls/fake/testdata/client-hello-ok-79d01ef18a9d2621.json create mode 100644 mtglib/internal/tls/fake/testdata/client-hello-ok-7a5569f05b118145.json diff --git a/.mise.toml b/.mise.toml index f95845b..c5bdf73 100644 --- a/.mise.toml +++ b/.mise.toml @@ -48,7 +48,7 @@ depends = [ [tasks."test:fuzz:client-hello"] description = "Run fuzzy test for ClientHello" -run = "go test -v {{ vars.fuzzflags }} -fuzz=FuzzClientHello ./mtglib/internal/faketls" +run = "go test -v {{ vars.fuzzflags }} -fuzz=FuzzReadClientHello ./mtglib/internal/tls/fake" [tasks."test:fuzz:client-handshake"] description = "Run fuzzy test for ClientHandshake" diff --git a/mtglib/internal/tls/fake/client_side.go b/mtglib/internal/tls/fake/client_side.go new file mode 100644 index 0000000..dc41397 --- /dev/null +++ b/mtglib/internal/tls/fake/client_side.go @@ -0,0 +1,305 @@ +package fake + +import ( + "bytes" + "crypto/hmac" + "crypto/sha256" + "crypto/subtle" + "encoding/binary" + "fmt" + "io" + "net" + "slices" + "time" + + "github.com/9seconds/mtg/v2/mtglib" + "github.com/9seconds/mtg/v2/mtglib/internal/tls" +) + +const ( + TypeHandshakeClient = 0x01 + + RandomLen = 32 + + // record_type(1) + version(2) + size(2) + handshake_type(1) + uint24_length(3) + client_version(2) + clientRandomOffset = 1 + 2 + 2 + 1 + 3 + 2 + sniDNSNamesListType = 0 +) + +var ( + emptyRandom = [RandomLen]byte{} + extTypeSNI = [2]byte{} +) + +type ClientHello struct { + Random [RandomLen]byte + SessionID []byte + CipherSuite uint16 +} + +func ReadClientHello(conn net.Conn, secret mtglib.Secret, tolerateTimeSkewness time.Duration) (*ClientHello, error) { + if err := conn.SetReadDeadline(time.Now().Add(ClientHelloReadTimeout)); err != nil { + return nil, fmt.Errorf("cannot set read deadline: %w", err) + } + defer conn.SetReadDeadline(resetDeadline) //nolint: errcheck + + // This is how FakeTLS is organized: + // 1. We create sha256 HMAC with a given secret + // 2. We dump there a whole TLS frame except of the fact that random + // is filled with all zeroes + // 3. Digest is computed. This digest should be XORed with + // original client random + // 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) + if err != nil { + return nil, fmt.Errorf("cannot parse 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) + if err != nil { + return nil, fmt.Errorf("cannot parse handshake: %w", err) + } + + sniHostnames, err := parseSNI(reader) + if err != nil { + return nil, fmt.Errorf("cannot parse SNI: %w", err) + } + + if !slices.Contains(sniHostnames, secret.Host) { + return nil, fmt.Errorf("cannot find %s in %v", secret.Host, sniHostnames) + } + + digest := hmac.New(sha256.New, secret.Key[:]) + // we write a copy of the handshake with client random all nullified. + digest.Write(handshakeCopyBuf.Next(clientRandomOffset)) + handshakeCopyBuf.Next(RandomLen) + digest.Write(emptyRandom[:]) + digest.Write(handshakeCopyBuf.Bytes()) + + computed := digest.Sum(nil) + + for i := range RandomLen { + computed[i] ^= hello.Random[i] + } + + if subtle.ConstantTimeCompare(emptyRandom[:RandomLen-4], computed[:RandomLen-4]) != 1 { + return nil, ErrBadDigest + } + + timestamp := int64(binary.LittleEndian.Uint32(computed[RandomLen-4:])) + createdAt := time.Unix(timestamp, 0) + + if tdiff := time.Since(createdAt).Abs(); tdiff > tolerateTimeSkewness { + return nil, fmt.Errorf("timestamp %q is too old %s", createdAt, tdiff) + } + + 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{} + + if _, err := io.ReadFull(r, header[:]); err != nil { + return nil, fmt.Errorf("cannot read record header: %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]) + } + + length := int64(binary.BigEndian.Uint16(header[3:])) + buf := &bytes.Buffer{} + + _, err := io.CopyN(buf, r, length) + + return buf, err +} + +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{} + + if _, err := io.ReadFull(r, header[:]); err != nil { + return nil, fmt.Errorf("cannot read client version: %w", err) + } + + hello := &ClientHello{} + + if _, err := io.ReadFull(r, hello.Random[:]); err != nil { + return nil, fmt.Errorf("cannot read client random: %w", err) + } + + if _, err := io.ReadFull(r, header[:1]); err != nil { + return nil, fmt.Errorf("cannot read session ID length: %w", err) + } + + hello.SessionID = make([]byte, int(header[0])) + + if _, err := io.ReadFull(r, hello.SessionID); err != nil { + return nil, fmt.Errorf("cannot read session id: %w", err) + } + + if _, err := io.ReadFull(r, header[:]); err != nil { + return nil, fmt.Errorf("cannot read cipher suite length: %w", err) + } + + cipherSuiteLen := int64(binary.BigEndian.Uint16(header[:])) + + // we do not care about picking up any cipher. we pick the first one, + // so it is always should be present. + if _, err := io.ReadFull(r, header[:]); err != nil { + return nil, fmt.Errorf("cannot read first cipher suite: %w", err) + } + + hello.CipherSuite = binary.BigEndian.Uint16(header[:]) + + if _, err := io.CopyN(io.Discard, r, cipherSuiteLen-2); err != nil { + return nil, fmt.Errorf("cannot skip remaining cipher suites: %w", err) + } + + if _, err := io.ReadFull(r, header[:1]); err != nil { + return nil, fmt.Errorf("cannot read compression methods length: %w", err) + } + + if _, err := io.CopyN(io.Discard, r, int64(header[0])); err != nil { + return nil, fmt.Errorf("cannot skip compression methods: %w", err) + } + + return hello, nil +} + +func parseSNI(r io.Reader) ([]string, error) { + header := [2]byte{} + + if _, err := io.ReadFull(r, header[:]); err != nil { + return nil, fmt.Errorf("cannot read length of TLS extensions: %w", err) + } + + extensionsLength := int64(binary.BigEndian.Uint16(header[:])) + buf := &bytes.Buffer{} + buf.Grow(int(extensionsLength)) + + if _, err := io.CopyN(buf, r, extensionsLength); err != nil { + return nil, fmt.Errorf("cannot read extensions: %w", err) + } + + for buf.Len() > 0 { + // 00 00 - assigned value for extension "server name" + // 00 18 - 0x18 (24) bytes of "server name" extension data follows + // 00 16 - 0x16 (22) bytes of first (and only) list entry follows + // 00 - list entry is type 0x00 "DNS hostname" + // 00 13 - 0x13 (19) bytes of hostname follows + // 65 78 61 ... 6e 65 74 - "example.ulfheim.net" + + // 00 00 - assigned value for extension "server name" + extTypeB := buf.Next(2) + if len(extTypeB) != 2 { + return nil, fmt.Errorf("cannot read extension type: %v", extTypeB) + } + + // 00 18 - 0x18 (24) bytes of "server name" extension data follows + lengthB := buf.Next(2) + if len(lengthB) != 2 { + return nil, fmt.Errorf("cannot read extension %v length: %v", extTypeB, lengthB) + } + length := int(binary.BigEndian.Uint16(lengthB)) + + extDataB := buf.Next(length) + if len(extDataB) != length { + return nil, fmt.Errorf("cannot read extension %v data: len %d != %d", extTypeB, length, len(extDataB)) + } + + if !bytes.Equal(extTypeB, extTypeSNI[:]) { + continue + } + + buf.Reset() + buf.Write(extDataB) + + // 00 16 - 0x16 (22) bytes of first (and only) list entry follows + lengthB = buf.Next(2) + if len(lengthB) != 2 { + return nil, fmt.Errorf("cannot read the length of the SNI record: %v", lengthB) + } + + length = int(binary.BigEndian.Uint16(lengthB)) + if length == 0 { + return nil, nil + } + + listType, err := buf.ReadByte() + if err != nil { + return nil, fmt.Errorf("cannot read SNI list type: %w", err) + } + + // 00 - list entry is type 0x00 "DNS hostname" + if listType != sniDNSNamesListType { + return nil, fmt.Errorf("incorrect SNI list type %#x", listType) + } + + names := []string{} + + for buf.Len() > 0 { + // 00 13 - 0x13 (19) bytes of hostname follows + lengthB = buf.Next(2) + if len(lengthB) != 2 { + return nil, fmt.Errorf("incorrect length of the hostname: %v", lengthB) + } + length = int(binary.BigEndian.Uint16(lengthB)) + + name := buf.Next(length) + if len(name) != length { + return nil, fmt.Errorf("incorrect length of SNI hostname: len %d != %d", length, len(name)) + } + + names = append(names, string(name)) + } + + return names, nil + } + + return nil, nil +} diff --git a/mtglib/internal/tls/fake/client_side_fuzz_test.go b/mtglib/internal/tls/fake/client_side_fuzz_test.go new file mode 100644 index 0000000..109873b --- /dev/null +++ b/mtglib/internal/tls/fake/client_side_fuzz_test.go @@ -0,0 +1,49 @@ +package fake_test + +import ( + "bytes" + "testing" + "time" + + "github.com/9seconds/mtg/v2/internal/testlib" + "github.com/9seconds/mtg/v2/mtglib" + "github.com/9seconds/mtg/v2/mtglib/internal/tls/fake" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +type connMock struct { + testlib.EssentialsConnMock + + readBuf *bytes.Buffer +} + +func (f *connMock) Read(p []byte) (int, error) { + return f.readBuf.Read(p) +} + +func FuzzReadClientHello(f *testing.F) { + seed := [248]byte{} + + secret, err := mtglib.ParseSecret( + "ee367a189aee18fa31c190054efd4a8e9573746f726167652e676f6f676c65617069732e636f6d", + ) + require.NoError(f, err) + + f.Add(seed[:]) + + f.Fuzz(func(t *testing.T, value []byte) { + r := &connMock{ + readBuf: bytes.NewBuffer(value), + } + r. + On("SetReadDeadline", mock.AnythingOfType("time.Time")). + Twice(). + Return(nil) + + _, err := fake.ReadClientHello(r, secret, time.Hour) + assert.Error(t, err) + }) + +} diff --git a/mtglib/internal/tls/fake/client_side_snapshot_test.go b/mtglib/internal/tls/fake/client_side_snapshot_test.go new file mode 100644 index 0000000..fc60ce7 --- /dev/null +++ b/mtglib/internal/tls/fake/client_side_snapshot_test.go @@ -0,0 +1,143 @@ +package fake_test + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/9seconds/mtg/v2/mtglib" + "github.com/9seconds/mtg/v2/mtglib/internal/tls/fake" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" +) + +type clientHelloSnapshot struct { + Time int `json:"time"` + Random string `json:"random"` + SessionID string `json:"sessionId"` + Host string `json:"host"` + CipherSuite int `json:"cipherSuite"` + Full string `json:"full"` +} + +func (c clientHelloSnapshot) GetRandom() []byte { + data, _ := base64.StdEncoding.DecodeString(c.Random) + + return data +} + +func (c clientHelloSnapshot) GetSessionID() []byte { + data, _ := base64.StdEncoding.DecodeString(c.SessionID) + + return data +} + +func (c clientHelloSnapshot) GetCipherSuite() uint16 { + return uint16(c.CipherSuite) +} + +func (c clientHelloSnapshot) GetFull() []byte { + data, _ := base64.StdEncoding.DecodeString(c.Full) + + return data +} + +type ParseClientHelloSnapshotTestSuite struct { + suite.Suite + + secret mtglib.Secret +} + +func (suite *ParseClientHelloSnapshotTestSuite) SetupSuite() { + parsed, err := mtglib.ParseSecret( + "ee367a189aee18fa31c190054efd4a8e9573746f726167652e676f6f676c65617069732e636f6d", + ) + require.NoError(suite.T(), err) + + suite.secret = parsed +} + +func (suite *ParseClientHelloSnapshotTestSuite) 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 (suite *ParseClientHelloSnapshotTestSuite) TestSnapshotOk() { + files, err := os.ReadDir("testdata") + require.NoError(suite.T(), err) + + for _, v := range files { + if !strings.HasPrefix(v.Name(), "client-hello-ok") { + continue + } + + path := filepath.Join("testdata", v.Name()) + + suite.T().Run(v.Name(), func(t *testing.T) { + fileData, err := os.ReadFile(path) + assert.NoError(t, err) + + snapshot := &clientHelloSnapshot{} + assert.NoError(t, json.Unmarshal(fileData, snapshot)) + + connMock := suite.makeConn(snapshot.GetFull()) + defer connMock.AssertExpectations(t) + + hello, err := fake.ReadClientHello(connMock, suite.secret, TolerateTime) + require.NoError(t, err) + + assert.Equal(t, snapshot.GetRandom(), hello.Random[:]) + assert.Equal(t, snapshot.GetSessionID(), hello.SessionID) + assert.Equal(t, snapshot.GetCipherSuite(), hello.CipherSuite) + }) + } +} + +func (suite *ParseClientHelloSnapshotTestSuite) TestSnapshotBad() { + files, err := os.ReadDir("testdata") + require.NoError(suite.T(), err) + + for _, v := range files { + if !strings.HasPrefix(v.Name(), "client-hello-bad") { + continue + } + + path := filepath.Join("testdata", v.Name()) + + suite.T().Run(v.Name(), func(t *testing.T) { + fileData, err := os.ReadFile(path) + assert.NoError(t, err) + + snapshot := &clientHelloSnapshot{} + assert.NoError(t, json.Unmarshal(fileData, snapshot)) + + connMock := suite.makeConn(snapshot.GetFull()) + defer connMock.AssertExpectations(t) + + _, err = fake.ReadClientHello(connMock, suite.secret, TolerateTime) + assert.ErrorIs(t, err, fake.ErrBadDigest) + }) + } +} + +func TestParseClientHelloSnapshot(t *testing.T) { + t.Parallel() + suite.Run(t, &ParseClientHelloSnapshotTestSuite{}) +} diff --git a/mtglib/internal/tls/fake/client_side_test.go b/mtglib/internal/tls/fake/client_side_test.go new file mode 100644 index 0000000..edf5901 --- /dev/null +++ b/mtglib/internal/tls/fake/client_side_test.go @@ -0,0 +1,395 @@ +package fake_test + +import ( + "bytes" + "encoding/binary" + "errors" + "io" + "testing" + "time" + + "github.com/9seconds/mtg/v2/internal/testlib" + "github.com/9seconds/mtg/v2/mtglib" + "github.com/9seconds/mtg/v2/mtglib/internal/tls" + "github.com/9seconds/mtg/v2/mtglib/internal/tls/fake" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" +) + +const ( + TolerateTime = 365 * 30 * 24 * time.Hour +) + +type parseClientHelloConnMock struct { + testlib.EssentialsConnMock + + readBuf *bytes.Buffer +} + +func (m *parseClientHelloConnMock) Read(p []byte) (int, error) { + return m.readBuf.Read(p) +} + +type ParseClientHelloTestSuite struct { + suite.Suite + + secret mtglib.Secret + readBuf *bytes.Buffer + connMock *parseClientHelloConnMock +} + +func (suite *ParseClientHelloTestSuite) SetupSuite() { + parsed, err := mtglib.ParseSecret("ee367a189aee18fa31c190054efd4a8e9573746f726167652e676f6f676c65617069732e636f6d") + require.NoError(suite.T(), err) + + suite.secret = parsed +} + +func (suite *ParseClientHelloTestSuite) SetupTest() { + suite.readBuf = &bytes.Buffer{} + suite.connMock = &parseClientHelloConnMock{ + readBuf: suite.readBuf, + } + + suite.connMock. + On("SetReadDeadline", mock.AnythingOfType("time.Time")). + Twice(). + Return(nil) +} + +func (suite *ParseClientHelloTestSuite) TearDownTest() { + suite.connMock.AssertExpectations(suite.T()) +} + +type ParseClientHello_TLSHeaderTestSuite struct { + ParseClientHelloTestSuite +} + +func (suite *ParseClientHello_TLSHeaderTestSuite) TestEmpty() { + suite.connMock.ExpectedCalls = []*mock.Call{} + suite.connMock. + On("SetReadDeadline", mock.AnythingOfType("time.Time")). + Once(). + Return(errors.New("fail")) + + _, err := fake.ReadClientHello(suite.connMock, suite.secret, TolerateTime) + suite.ErrorContains(err, "fail") +} + +func (suite *ParseClientHello_TLSHeaderTestSuite) TestNothing() { + suite.connMock.ExpectedCalls = []*mock.Call{} + suite.connMock. + On("SetReadDeadline", mock.AnythingOfType("time.Time")). + Twice(). + Return(nil) + + _, err := fake.ReadClientHello(suite.connMock, suite.secret, TolerateTime) + suite.ErrorIs(err, io.EOF) +} + +func (suite *ParseClientHello_TLSHeaderTestSuite) TestUnknownRecord() { + suite.readBuf.Write([]byte{ + 10, + 3, 3, + 0, 0, + }) + suite.readBuf.WriteByte(10) + + _, err := fake.ReadClientHello(suite.connMock, suite.secret, TolerateTime) + suite.ErrorContains(err, "unexpected record type 0xa") +} + +func (suite *ParseClientHello_TLSHeaderTestSuite) TestUnknownProtocolVersion() { + suite.readBuf.Write([]byte{ + tls.TypeHandshake, + 3, 3, + 0, 0, + }) + + _, err := fake.ReadClientHello(suite.connMock, suite.secret, TolerateTime) + suite.ErrorContains(err, "unexpected protocol version") +} + +func (suite *ParseClientHello_TLSHeaderTestSuite) TestCannotReadRestOfRecord() { + suite.readBuf.Write([]byte{ + tls.TypeHandshake, + 3, 1, + 0, 10, + }) + + _, err := fake.ReadClientHello(suite.connMock, suite.secret, TolerateTime) + suite.ErrorIs(err, io.EOF) +} + +type ParseClientHelloHandshakeTestSuite struct { + ParseClientHelloTestSuite +} + +func (suite *ParseClientHelloHandshakeTestSuite) SetupTest() { + suite.ParseClientHelloTestSuite.SetupTest() + + suite.readBuf.Write([]byte{ + tls.TypeHandshake, + 3, 1, + 0, + }) +} + +func (suite *ParseClientHelloHandshakeTestSuite) TestCannotReadHeader() { + suite.readBuf.Write([]byte{ + 1, + 10, + }) + + _, err := fake.ReadClientHello(suite.connMock, suite.secret, TolerateTime) + suite.ErrorContains(err, "cannot read handshake header") +} + +func (suite *ParseClientHelloHandshakeTestSuite) TestIncorrectHandshakeType() { + suite.readBuf.Write([]byte{ + 4, + 10, 0, 0, 0, + }) + + _, err := fake.ReadClientHello(suite.connMock, suite.secret, TolerateTime) + suite.ErrorContains(err, "incorrect handshake type") +} + +func (suite *ParseClientHelloHandshakeTestSuite) TestCannotReadHandshake() { + suite.readBuf.Write([]byte{ + 4 + 3, + 10, 0, 0, 0, + }) + + _, err := fake.ReadClientHello(suite.connMock, suite.secret, TolerateTime) + suite.ErrorIs(err, io.EOF) +} + +type ParseClientHelloHandshakeBodyTestSuite struct { + ParseClientHelloTestSuite +} + +func (suite *ParseClientHelloHandshakeBodyTestSuite) SetupTest() { + suite.ParseClientHelloTestSuite.SetupTest() + + suite.readBuf.Write([]byte{ + tls.TypeHandshake, + 3, 1, + 0, + }) +} + +func (suite *ParseClientHelloHandshakeBodyTestSuite) writeBody(body []byte) { + suite.readBuf.WriteByte(byte(4 + len(body))) + suite.readBuf.Write([]byte{ + fake.TypeHandshakeClient, + 0, 0, byte(len(body)), + }) + suite.readBuf.Write(body) +} + +func (suite *ParseClientHelloHandshakeBodyTestSuite) TestCannotReadVersion() { + suite.writeBody(nil) + + _, err := fake.ReadClientHello(suite.connMock, suite.secret, TolerateTime) + suite.ErrorContains(err, "cannot read client version") +} + +func (suite *ParseClientHelloHandshakeBodyTestSuite) TestCannotReadRandom() { + suite.writeBody([]byte{3, 3}) + + _, err := fake.ReadClientHello(suite.connMock, suite.secret, TolerateTime) + suite.ErrorContains(err, "cannot read client random") +} + +func (suite *ParseClientHelloHandshakeBodyTestSuite) TestCannotReadSessionIDLength() { + body := make([]byte, 2+fake.RandomLen) + + suite.writeBody(body) + + _, err := fake.ReadClientHello(suite.connMock, suite.secret, TolerateTime) + suite.ErrorContains(err, "cannot read session ID length") +} + +func (suite *ParseClientHelloHandshakeBodyTestSuite) TestCannotReadSessionID() { + body := make([]byte, 2+fake.RandomLen+1) + body[2+fake.RandomLen] = 32 + + suite.writeBody(body) + + _, err := fake.ReadClientHello(suite.connMock, suite.secret, TolerateTime) + suite.ErrorContains(err, "cannot read session id") +} + +func (suite *ParseClientHelloHandshakeBodyTestSuite) TestCannotReadCipherSuiteLength() { + body := make([]byte, 2+fake.RandomLen+1) + + suite.writeBody(body) + + _, err := fake.ReadClientHello(suite.connMock, suite.secret, TolerateTime) + suite.ErrorContains(err, "cannot read cipher suite length") +} + +func (suite *ParseClientHelloHandshakeBodyTestSuite) TestCannotReadFirstCipherSuite() { + body := make([]byte, 2+fake.RandomLen+1+2) + + suite.writeBody(body) + + _, err := fake.ReadClientHello(suite.connMock, suite.secret, TolerateTime) + suite.ErrorContains(err, "cannot read first cipher suite") +} + +func (suite *ParseClientHelloHandshakeBodyTestSuite) TestCannotSkipRemainingCipherSuites() { + body := make([]byte, 2+fake.RandomLen+1+2+2) + binary.BigEndian.PutUint16(body[2+fake.RandomLen+1:], 4) + + suite.writeBody(body) + + _, err := fake.ReadClientHello(suite.connMock, suite.secret, TolerateTime) + suite.ErrorContains(err, "cannot skip remaining cipher suites") +} + +func (suite *ParseClientHelloHandshakeBodyTestSuite) TestCannotReadCompressionMethodsLength() { + body := make([]byte, 2+fake.RandomLen+1+2+2) + binary.BigEndian.PutUint16(body[2+fake.RandomLen+1:], 2) + + suite.writeBody(body) + + _, err := fake.ReadClientHello(suite.connMock, suite.secret, TolerateTime) + suite.ErrorContains(err, "cannot read compression methods length") +} + +func (suite *ParseClientHelloHandshakeBodyTestSuite) TestCannotSkipCompressionMethods() { + body := make([]byte, 2+fake.RandomLen+1+2+2+1) + binary.BigEndian.PutUint16(body[2+fake.RandomLen+1:], 2) + body[2+fake.RandomLen+1+2+2] = 1 + + suite.writeBody(body) + + _, err := fake.ReadClientHello(suite.connMock, suite.secret, TolerateTime) + suite.ErrorContains(err, "cannot skip compression methods") +} + +type ParseClientHelloSNITestSuite struct { + ParseClientHelloTestSuite +} + +func (suite *ParseClientHelloSNITestSuite) SetupTest() { + suite.ParseClientHelloTestSuite.SetupTest() + + suite.readBuf.Write([]byte{ + tls.TypeHandshake, + 3, 1, + 0, + }) +} + +func (suite *ParseClientHelloSNITestSuite) writeExtensions(extensions []byte) { + handshakeBodyLen := 41 + len(extensions) + + suite.readBuf.WriteByte(byte(4 + handshakeBodyLen)) + suite.readBuf.Write([]byte{ + fake.TypeHandshakeClient, + 0, 0, byte(handshakeBodyLen), + }) + + // version(2) + random(32) + sessionIDLen(1) + cipherSuiteLen(2) + + // cipherSuite(2) + compressionLen(1) + compression(1) = 41 + body := make([]byte, 41) + binary.BigEndian.PutUint16(body[35:], 2) + body[39] = 1 + + suite.readBuf.Write(body) + suite.readBuf.Write(extensions) +} + +func (suite *ParseClientHelloSNITestSuite) TestCannotReadExtensionsLength() { + suite.writeExtensions(nil) + + _, err := fake.ReadClientHello(suite.connMock, suite.secret, TolerateTime) + suite.ErrorContains(err, "cannot read length of TLS extensions") +} + +func (suite *ParseClientHelloSNITestSuite) TestCannotReadExtensions() { + suite.writeExtensions([]byte{0, 10}) + + _, err := fake.ReadClientHello(suite.connMock, suite.secret, TolerateTime) + suite.ErrorContains(err, "cannot read extensions") +} + +func (suite *ParseClientHelloSNITestSuite) TestCannotReadExtensionType() { + suite.writeExtensions([]byte{0, 1, 0xAB}) + + _, err := fake.ReadClientHello(suite.connMock, suite.secret, TolerateTime) + suite.ErrorContains(err, "cannot read extension type") +} + +func (suite *ParseClientHelloSNITestSuite) TestCannotReadExtensionLength() { + suite.writeExtensions([]byte{0, 2, 0xFF, 0xFF}) + + _, err := fake.ReadClientHello(suite.connMock, suite.secret, TolerateTime) + suite.ErrorContains(err, "length:") +} + +func (suite *ParseClientHelloSNITestSuite) TestCannotReadExtensionData() { + suite.writeExtensions([]byte{0, 4, 0xFF, 0xFF, 0, 5}) + + _, err := fake.ReadClientHello(suite.connMock, suite.secret, TolerateTime) + suite.ErrorContains(err, "data: len") +} + +func (suite *ParseClientHelloSNITestSuite) TestCannotReadSNIRecordLength() { + suite.writeExtensions([]byte{0, 5, 0, 0, 0, 1, 0xAB}) + + _, err := fake.ReadClientHello(suite.connMock, suite.secret, TolerateTime) + suite.ErrorContains(err, "cannot read the length of the SNI record") +} + +func (suite *ParseClientHelloSNITestSuite) TestCannotReadSNIListType() { + suite.writeExtensions([]byte{0, 6, 0, 0, 0, 2, 0, 1}) + + _, err := fake.ReadClientHello(suite.connMock, suite.secret, TolerateTime) + suite.ErrorContains(err, "cannot read SNI list type") +} + +func (suite *ParseClientHelloSNITestSuite) TestIncorrectSNIListType() { + suite.writeExtensions([]byte{0, 7, 0, 0, 0, 3, 0, 1, 5}) + + _, err := fake.ReadClientHello(suite.connMock, suite.secret, TolerateTime) + suite.ErrorContains(err, "incorrect SNI list type") +} + +func (suite *ParseClientHelloSNITestSuite) TestCannotReadHostnameLength() { + suite.writeExtensions([]byte{0, 8, 0, 0, 0, 4, 0, 2, 0, 0xAB}) + + _, err := fake.ReadClientHello(suite.connMock, suite.secret, TolerateTime) + suite.ErrorContains(err, "incorrect length of the hostname") +} + +func (suite *ParseClientHelloSNITestSuite) TestCannotReadHostname() { + suite.writeExtensions([]byte{0, 9, 0, 0, 0, 5, 0, 3, 0, 0, 5}) + + _, err := fake.ReadClientHello(suite.connMock, suite.secret, TolerateTime) + suite.ErrorContains(err, "incorrect length of SNI hostname") +} + +func TestParseClientHelloTLSHeader(t *testing.T) { + t.Parallel() + suite.Run(t, &ParseClientHello_TLSHeaderTestSuite{}) +} + +func TestParseClientHelloHandshake(t *testing.T) { + t.Parallel() + suite.Run(t, &ParseClientHelloHandshakeTestSuite{}) +} + +func TestParseClientHelloHandshakeBody(t *testing.T) { + t.Parallel() + suite.Run(t, &ParseClientHelloHandshakeBodyTestSuite{}) +} + +func TestParseClientHelloSNI(t *testing.T) { + t.Parallel() + suite.Run(t, &ParseClientHelloSNITestSuite{}) +} diff --git a/mtglib/internal/tls/fake/init.go b/mtglib/internal/tls/fake/init.go new file mode 100644 index 0000000..77bbe6b --- /dev/null +++ b/mtglib/internal/tls/fake/init.go @@ -0,0 +1,16 @@ +package fake + +import ( + "errors" + "time" +) + +const ( + ClientHelloReadTimeout = 5 * time.Second +) + +var ( + resetDeadline time.Time + + ErrBadDigest = errors.New("incorrect client random") +) diff --git a/mtglib/internal/tls/fake/testdata/client-hello-bad-fa2e46cdb33e2a1b.json b/mtglib/internal/tls/fake/testdata/client-hello-bad-fa2e46cdb33e2a1b.json new file mode 100644 index 0000000..3bacba3 --- /dev/null +++ b/mtglib/internal/tls/fake/testdata/client-hello-bad-fa2e46cdb33e2a1b.json @@ -0,0 +1,8 @@ +{ + "time": 1617181365, + "random": "XvCPc3aAbHbhRLv0kUmy6BfPZOGvsused5/HNsKXEPs=", + "sessionId": "St2BZ2uHMFn3B2trD1jfdtpjoJOOg6JBeLhFcyCMCq4=", + "host": "storage.googleapis.com", + "cipherSuite": 4867, + "full": "FgMBAgABAAH8AwNe8I9zdoBsduFEu/SRSbLoF89k4a+y6x53n8c2wpcQ+yBK3YFna4cwWfcHa2sPWN922mOgk46DokF4uEVzIIAKrgA0EwMTARMCwCzAK8AkwCPACsAJzKnAMMAvwCjAJ8AUwBPMqACdAJwAPQA8ADUAL8AIwBIACgEAAX//AQABAAAAABsAGQAAFnN0b3JhZ2UuZ29vZ2xlYXBpcy5jb20AFwAAAA0AGAAWBAMIBAQBBQMCAwgFCAUFAQgGBgECAQAFAAUA0AAAADN0AAAAEgAAABAAMAAuAmgyBWgyLTE2BWgyLTE1BWgyLTE0CHNwZHkvMy4xBnNwZHkvMwhodHRwLzEuMQALAAIBAAAzACYAJAAdACAH/ugvH0kSUgAuwslL3UfZA3JTUfSiwrAhR6VWd2wvIgAtAAIBAQArAAkIAwQDAwMCAwEACgAKAAgAHQAXABgAGQAVAKEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" +} diff --git a/mtglib/internal/tls/fake/testdata/client-hello-ok-19dfe38384b9884b.json b/mtglib/internal/tls/fake/testdata/client-hello-ok-19dfe38384b9884b.json new file mode 100644 index 0000000..e7a2ad5 --- /dev/null +++ b/mtglib/internal/tls/fake/testdata/client-hello-ok-19dfe38384b9884b.json @@ -0,0 +1,8 @@ +{ + "time": 1617181365, + "random": "XvCPc3aAbHbhRLv0kUmy6BfPZOGvsused5/HNsKXEPs=", + "sessionId": "St2BZ2uHMFn3B2trD1jfdtpjoJOOg6JBeLhFcyCMCq4=", + "host": "storage.googleapis.com", + "cipherSuite": 4867, + "full": "FgMBAgABAAH8AwNe8I9zdoBsduFEu/SRSbLoF89k4a+y6x53n8c2wpcQ+yBK3YFna4cwWfcHa2sPWN922mOgk46DokF4uEVzIIwKrgA0EwMTARMCwCzAK8AkwCPACsAJzKnAMMAvwCjAJ8AUwBPMqACdAJwAPQA8ADUAL8AIwBIACgEAAX//AQABAAAAABsAGQAAFnN0b3JhZ2UuZ29vZ2xlYXBpcy5jb20AFwAAAA0AGAAWBAMIBAQBBQMCAwgFCAUFAQgGBgECAQAFAAUBAAAAADN0AAAAEgAAABAAMAAuAmgyBWgyLTE2BWgyLTE1BWgyLTE0CHNwZHkvMy4xBnNwZHkvMwhodHRwLzEuMQALAAIBAAAzACYAJAAdACAH/ugvH0kSUgAuwslL3UfZA3JTUfSiwrAhR6VWd2wvIgAtAAIBAQArAAkIAwQDAwMCAwEACgAKAAgAHQAXABgAGQAVAKEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" +} diff --git a/mtglib/internal/tls/fake/testdata/client-hello-ok-48f8a72a56f3174a.json b/mtglib/internal/tls/fake/testdata/client-hello-ok-48f8a72a56f3174a.json new file mode 100644 index 0000000..e24429e --- /dev/null +++ b/mtglib/internal/tls/fake/testdata/client-hello-ok-48f8a72a56f3174a.json @@ -0,0 +1,8 @@ +{ + "time": 1617181352, + "random": "oYEu33jl+zQbUKMtQbV1OHB0gXIM2y2aq9iY0QX12os=", + "sessionId": "FGqA3ZFYrSlj//xl7lammNn64K9/MK2mQ3HJUGvP+8g=", + "host": "storage.googleapis.com", + "cipherSuite": 4867, + "full": "FgMBAgABAAH8AwOhgS7feOX7NBtQoy1BtXU4cHSBcgzbLZqr2JjRBfXaiyAUaoDdkVitKWP//GXuVqaY2frgr38wraZDcclQa8/7yAA0EwMTARMCwCzAK8AkwCPACsAJzKnAMMAvwCjAJ8AUwBPMqACdAJwAPQA8ADUAL8AIwBIACgEAAX//AQABAAAAABsAGQAAFnN0b3JhZ2UuZ29vZ2xlYXBpcy5jb20AFwAAAA0AGAAWBAMIBAQBBQMCAwgFCAUFAQgGBgECAQAFAAUBAAAAADN0AAAAEgAAABAAMAAuAmgyBWgyLTE2BWgyLTE1BWgyLTE0CHNwZHkvMy4xBnNwZHkvMwhodHRwLzEuMQALAAIBAAAzACYAJAAdACBroKhykU/xB3hgIVH2mRoKv3umjYAuPQ/mcj02dvdRYwAtAAIBAQArAAkIAwQDAwMCAwEACgAKAAgAHQAXABgAGQAVAKEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" +} diff --git a/mtglib/internal/tls/fake/testdata/client-hello-ok-651054256093c6cd.json b/mtglib/internal/tls/fake/testdata/client-hello-ok-651054256093c6cd.json new file mode 100644 index 0000000..517bc1b --- /dev/null +++ b/mtglib/internal/tls/fake/testdata/client-hello-ok-651054256093c6cd.json @@ -0,0 +1,8 @@ +{ + "time": 1617181352, + "random": "5V5sSprk/tFIgy+x1BeKNGhLlFkqfggLpgN7GYOA1ro=", + "sessionId": "jxr4d6PXPDk+Lwx3WUp9wvj8TGlOxEdrRJ0ydyJ9+H8=", + "host": "storage.googleapis.com", + "cipherSuite": 4867, + "full": "FgMBAgABAAH8AwPlXmxKmuT+0UiDL7HUF4o0aEuUWSp+CAumA3sZg4DWuiCPGvh3o9c8OT4vDHdZSn3C+PxMaU7ER2tEnTJ3In34fwA0EwMTARMCwCzAK8AkwCPACsAJzKnAMMAvwCjAJ8AUwBPMqACdAJwAPQA8ADUAL8AIwBIACgEAAX//AQABAAAAABsAGQAAFnN0b3JhZ2UuZ29vZ2xlYXBpcy5jb20AFwAAAA0AGAAWBAMIBAQBBQMCAwgFCAUFAQgGBgECAQAFAAUBAAAAADN0AAAAEgAAABAAMAAuAmgyBWgyLTE2BWgyLTE1BWgyLTE0CHNwZHkvMy4xBnNwZHkvMwhodHRwLzEuMQALAAIBAAAzACYAJAAdACCu6UBqpR0p5VgzQX6m7qif+HosGk7LM4objEUgpygWTgAtAAIBAQArAAkIAwQDAwMCAwEACgAKAAgAHQAXABgAGQAVAKEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" +} diff --git a/mtglib/internal/tls/fake/testdata/client-hello-ok-79d01ef18a9d2621.json b/mtglib/internal/tls/fake/testdata/client-hello-ok-79d01ef18a9d2621.json new file mode 100644 index 0000000..b8b0d63 --- /dev/null +++ b/mtglib/internal/tls/fake/testdata/client-hello-ok-79d01ef18a9d2621.json @@ -0,0 +1,8 @@ +{ + "time": 1617181365, + "random": "8xljlOhkDlkafEF5vu3e1r3fWvh8AX548wC3hLZ3szQ=", + "sessionId": "00uvDYKnFyZFKyf3HlLwWGCOyeHsPFiU5UZ+Fs5pDAU=", + "host": "storage.googleapis.com", + "cipherSuite": 4867, + "full": "FgMBAgABAAH8AwPzGWOU6GQOWRp8QXm+7d7Wvd9a+HwBfnjzALeEtnezNCDTS68NgqcXJkUrJ/ceUvBYYI7J4ew8WJTlRn4WzmkMBQA0EwMTARMCwCzAK8AkwCPACsAJzKnAMMAvwCjAJ8AUwBPMqACdAJwAPQA8ADUAL8AIwBIACgEAAX//AQABAAAAABsAGQAAFnN0b3JhZ2UuZ29vZ2xlYXBpcy5jb20AFwAAAA0AGAAWBAMIBAQBBQMCAwgFCAUFAQgGBgECAQAFAAUBAAAAADN0AAAAEgAAABAAMAAuAmgyBWgyLTE2BWgyLTE1BWgyLTE0CHNwZHkvMy4xBnNwZHkvMwhodHRwLzEuMQALAAIBAAAzACYAJAAdACD/0/vXjQ20rOPIPAF/32Y7LX4WNE8A8dM1D1bEc4qlXgAtAAIBAQArAAkIAwQDAwMCAwEACgAKAAgAHQAXABgAGQAVAKEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" +} diff --git a/mtglib/internal/tls/fake/testdata/client-hello-ok-7a5569f05b118145.json b/mtglib/internal/tls/fake/testdata/client-hello-ok-7a5569f05b118145.json new file mode 100644 index 0000000..944d586 --- /dev/null +++ b/mtglib/internal/tls/fake/testdata/client-hello-ok-7a5569f05b118145.json @@ -0,0 +1,8 @@ +{ + "time": 1617181352, + "random": "zja3MLZ8WGSfsQRtPV75+tY6gbK3zKPi1Sy7SBBafg4=", + "sessionId": "qPut2yMqXa9zGLII/872SQ3d4Tfqo0uoDb7tpkRfBnA=", + "host": "storage.googleapis.com", + "cipherSuite": 4867, + "full": "FgMBAgABAAH8AwPONrcwtnxYZJ+xBG09Xvn61jqBsrfMo+LVLLtIEFp+DiCo+63bIypdr3MYsgj/zvZJDd3hN+qjS6gNvu2mRF8GcAA0EwMTARMCwCzAK8AkwCPACsAJzKnAMMAvwCjAJ8AUwBPMqACdAJwAPQA8ADUAL8AIwBIACgEAAX//AQABAAAAABsAGQAAFnN0b3JhZ2UuZ29vZ2xlYXBpcy5jb20AFwAAAA0AGAAWBAMIBAQBBQMCAwgFCAUFAQgGBgECAQAFAAUBAAAAADN0AAAAEgAAABAAMAAuAmgyBWgyLTE2BWgyLTE1BWgyLTE0CHNwZHkvMy4xBnNwZHkvMwhodHRwLzEuMQALAAIBAAAzACYAJAAdACBe+ItECoBgnzE4t2VyxSGV0jheXSD+z37LZCt3yto8SAAtAAIBAQArAAkIAwQDAwMCAwEACgAKAAgAHQAXABgAGQAVAKEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" +}