diff --git a/go.mod b/go.mod index bd0bb53a75..064496ea77 100644 --- a/go.mod +++ b/go.mod @@ -8,8 +8,8 @@ retract ( ) require ( - github.com/andybalholm/brotli v1.0.6 github.com/klauspost/compress v1.17.4 + github.com/molecule-man/go-brrr v1.2.0 golang.org/x/crypto v0.36.0 golang.org/x/net v0.38.0 golang.org/x/sys v0.31.0 diff --git a/go.sum b/go.sum index 82793448f2..79a0968476 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ -github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI= -github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4= github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= +github.com/molecule-man/go-brrr v1.2.0 h1:dOJU45BC3Gc2WXoxAW0rgCAgVuu2gruAOGncfieA0Jw= +github.com/molecule-man/go-brrr v1.2.0/go.mod h1:7ybW6/7gA3oKY45jOfVNjSJDtrr6ea4tzbsTkjmQDC4= golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= @@ -10,3 +10,5 @@ golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +golang.org/x/tools v0.30.0 h1:BgcpHewrV5AUp2G9MebG4XPFI1E2W41zU1SaqVA9vJY= +golang.org/x/tools v0.30.0/go.mod h1:c347cR/OJfw5TI+GfX7RUPNMdDRRbjvYTS0jPyvsVtY= diff --git a/u_handshake_client.go b/u_handshake_client.go index 0a48dd261e..28818cedd6 100644 --- a/u_handshake_client.go +++ b/u_handshake_client.go @@ -14,8 +14,8 @@ import ( "io" "slices" - "github.com/andybalholm/brotli" "github.com/klauspost/compress/zstd" + "github.com/molecule-man/go-brrr" "github.com/refraction-networking/utls/internal/fips140tls" "github.com/refraction-networking/utls/internal/tls13" ) @@ -70,7 +70,9 @@ func (hs *clientHandshakeStateTLS13) decompressCert(m utlsCompressedCertificateM switch CertCompressionAlgo(m.algorithm) { case CertCompressionBrotli: - decompressed = brotli.NewReader(compressed) + rc := brrr.NewReader(compressed) + defer rc.Close() + decompressed = rc case CertCompressionZlib: rc, err := zlib.NewReader(compressed) @@ -101,8 +103,8 @@ func (hs *clientHandshakeStateTLS13) decompressCert(m utlsCompressedCertificateM rawMsg[2] = uint8(m.uncompressedLength >> 8) rawMsg[3] = uint8(m.uncompressedLength) - n, err := decompressed.Read(rawMsg[4:]) - if err != nil && !errors.Is(err, io.EOF) { + n, err := io.ReadFull(decompressed, rawMsg[4:]) + if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) { c.sendAlert(alertBadCertificate) return nil, err } @@ -113,6 +115,24 @@ func (hs *clientHandshakeStateTLS13) decompressCert(m utlsCompressedCertificateM c.sendAlert(alertBadCertificate) return nil, fmt.Errorf("decompressed len (%d) does not match specified len (%d)", n, m.uncompressedLength) } + // Read through the end of the stream to detect excess output and terminal + // decoder errors that may be deferred until after the last output byte. + var extra [1]byte + n, err = io.ReadFull(decompressed, extra[:]) + if n != 0 { + c.sendAlert(alertBadCertificate) + return nil, fmt.Errorf("decompressed len exceeds specified len (%d)", m.uncompressedLength) + } + if !errors.Is(err, io.EOF) { + c.sendAlert(alertBadCertificate) + return nil, err + } + // A decoder can stop at the end of its stream without consuming the rest + // of the compressed certificate message. + if compressed.Len() != 0 { + c.sendAlert(alertBadCertificate) + return nil, errors.New("trailing data after compressed certificate stream") + } certMsg := new(certificateMsgTLS13) if !certMsg.unmarshal(rawMsg) { return nil, c.sendAlert(alertUnexpectedMessage) diff --git a/u_handshake_client_brotli_test.go b/u_handshake_client_brotli_test.go new file mode 100644 index 0000000000..0e60a8e3cd --- /dev/null +++ b/u_handshake_client_brotli_test.go @@ -0,0 +1,159 @@ +// Copyright 2026 uTLS Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package tls + +import ( + "bytes" + "math/rand" + "testing" + + "github.com/molecule-man/go-brrr" +) + +func TestDecompressCertBrotli(t *testing.T) { + randomCertificate := make([]byte, 64<<10) + rand.New(rand.NewSource(0)).Read(randomCertificate) + for _, test := range []struct { + name string + certificate []byte + window int + }{ + {"small", []byte("test certificate"), 0}, + {"multiple_1KiB_windows", bytes.Repeat([]byte("certificate"), 1024), 10}, + {"multiple_4KiB_windows", bytes.Repeat([]byte("certificate"), 1024), 12}, + {"multiple_input_buffers", randomCertificate, 0}, + } { + t.Run(test.name, func(t *testing.T) { + body := marshalBrotliTestCertificate(t, test.certificate) + compressed := compressBrotliTestCertificate(t, body, test.window) + if test.name == "multiple_input_buffers" && len(compressed) <= 32<<10 { + t.Fatalf("compressed certificate is only %d bytes; want more than 32 KiB", len(compressed)) + } + got, err := brotliTestHandshake().decompressCert(utlsCompressedCertificateMsg{ + algorithm: uint16(CertCompressionBrotli), + uncompressedLength: uint32(len(body)), + compressedCertificateMessage: compressed, + }) + if err != nil { + t.Fatal(err) + } + if len(got.certificate.Certificate) != 1 || !bytes.Equal(got.certificate.Certificate[0], test.certificate) { + t.Fatal("decompressed certificate differs from the original") + } + }) + } +} + +func TestDecompressCertBrotliRejectsInvalidMessages(t *testing.T) { + body := marshalBrotliTestCertificate(t, []byte("test certificate")) + compressed := compressBrotliTestCertificate(t, body, 0) + for _, test := range []struct { + name string + compressed []byte + length uint32 + }{ + {"trailing_compressed_bytes", append(bytes.Clone(compressed), 0, 1), uint32(len(body))}, + {"extra_decompressed_byte", compressBrotliTestCertificate(t, append(bytes.Clone(body), 0), 0), uint32(len(body))}, + {"truncated_stream", compressed[:len(compressed)-1], uint32(len(body))}, + {"advertised_length_too_large", compressed, uint32(len(body) + 1)}, + {"advertised_length_too_small", compressed, uint32(len(body) - 1)}, + } { + t.Run(test.name, func(t *testing.T) { + hs := brotliTestHandshake() + _, err := hs.decompressCert(utlsCompressedCertificateMsg{ + algorithm: uint16(CertCompressionBrotli), + uncompressedLength: test.length, + compressedCertificateMessage: test.compressed, + }) + if err == nil { + t.Fatal("accepted an invalid compressed certificate message") + } + if len(hs.c.sendBuf) < 2 || hs.c.sendBuf[len(hs.c.sendBuf)-1] != byte(alertBadCertificate) { + t.Fatalf("sent alert %x, want bad_certificate", hs.c.sendBuf) + } + }) + } +} + +func TestDecompressCertBrotliTrailingBytesAtInputBoundary(t *testing.T) { + // This independently encoded stream has a 22-bit window, one uncompressed + // metablock containing the 32,764-byte certificate body, and an empty final + // metablock. Its 32 KiB size ends exactly at the reader's input boundary, + // leaving any appended bytes unread when the decoder reports EOF. + certificate := bytes.Repeat([]byte("a"), 32755) + body := marshalBrotliTestCertificate(t, certificate) + compressed := append([]byte{0x8b, 0xfd, 0xbf}, body...) + compressed = append(compressed, 0x03) + if len(compressed) != 32<<10 { + t.Fatalf("fixture length = %d, want 32 KiB", len(compressed)) + } + for _, trailingBytes := range []bool{false, true} { + name := "valid_stream" + message := bytes.Clone(compressed) + if trailingBytes { + name = "trailing_bytes" + message = append(message, 0, 1) + } + t.Run(name, func(t *testing.T) { + hs := brotliTestHandshake() + got, err := hs.decompressCert(utlsCompressedCertificateMsg{ + algorithm: uint16(CertCompressionBrotli), + uncompressedLength: uint32(len(body)), + compressedCertificateMessage: message, + }) + if trailingBytes { + if err == nil { + t.Fatal("accepted bytes after the compressed certificate stream") + } + if len(hs.c.sendBuf) < 2 || hs.c.sendBuf[len(hs.c.sendBuf)-1] != byte(alertBadCertificate) { + t.Fatalf("sent alert %x, want bad_certificate", hs.c.sendBuf) + } + return + } + if err != nil { + t.Fatal(err) + } + if len(got.certificate.Certificate) != 1 || !bytes.Equal(got.certificate.Certificate[0], certificate) { + t.Fatal("decompressed certificate differs from the original") + } + }) + } +} + +func brotliTestHandshake() *clientHandshakeStateTLS13 { + return &clientHandshakeStateTLS13{ + c: &Conn{buffering: true, config: &Config{}}, + uconn: &UConn{ + certCompressionAlgs: []CertCompressionAlgo{CertCompressionBrotli}, + }, + } +} + +func marshalBrotliTestCertificate(t *testing.T, certificate []byte) []byte { + t.Helper() + original, err := (&certificateMsgTLS13{certificate: Certificate{ + Certificate: [][]byte{certificate}, + }}).marshal() + if err != nil { + t.Fatal(err) + } + return original[4:] +} + +func compressBrotliTestCertificate(t *testing.T, body []byte, window int) []byte { + t.Helper() + var compressed bytes.Buffer + w, err := brrr.NewWriterOptions(&compressed, 5, brrr.WriterOptions{LGWin: window}) + if err != nil { + t.Fatal(err) + } + if _, err := w.Write(body); err != nil { + t.Fatal(err) + } + if err := w.Close(); err != nil { + t.Fatal(err) + } + return compressed.Bytes() +} diff --git a/u_handshake_client_compression_test.go b/u_handshake_client_compression_test.go new file mode 100644 index 0000000000..1e5c391591 --- /dev/null +++ b/u_handshake_client_compression_test.go @@ -0,0 +1,143 @@ +// Copyright 2026 uTLS Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package tls + +import ( + "bytes" + "compress/zlib" + "math/rand" + "testing" + + "github.com/klauspost/compress/zstd" +) + +func TestDecompressCertZlibAndZstd(t *testing.T) { + incompressibleCertificate := make([]byte, 256<<10) + rand.New(rand.NewSource(0)).Read(incompressibleCertificate) + for _, codec := range certificateTestCodecs { + t.Run(codec.name, func(t *testing.T) { + for _, test := range []struct { + name string + certificate []byte + }{ + {"small", []byte("test certificate")}, + {"large_compressible", bytes.Repeat([]byte("certificate"), 32<<10)}, + {"large_incompressible", incompressibleCertificate}, + } { + t.Run(test.name, func(t *testing.T) { + body := marshalCompressionTestCertificate(t, test.certificate) + compressed := codec.compress(t, body) + if test.name == "large_incompressible" && len(compressed) <= 128<<10 { + t.Fatalf("compressed certificate is only %d bytes; want more than 128 KiB", len(compressed)) + } + hs := compressionTestHandshake(codec.algorithm) + got, err := hs.decompressCert(utlsCompressedCertificateMsg{ + algorithm: uint16(codec.algorithm), + uncompressedLength: uint32(len(body)), + compressedCertificateMessage: compressed, + }) + if err != nil { + t.Fatal(err) + } + if got == nil || len(got.certificate.Certificate) != 1 || !bytes.Equal(got.certificate.Certificate[0], test.certificate) { + t.Fatal("decompressed certificate differs from the original") + } + if len(hs.c.sendBuf) != 0 { + t.Fatalf("sent an alert for a valid certificate: %x", hs.c.sendBuf) + } + }) + } + }) + } +} + +func TestDecompressCertZlibAndZstdRejectsInvalidMessages(t *testing.T) { + body := marshalCompressionTestCertificate(t, []byte("test certificate")) + for _, codec := range certificateTestCodecs { + t.Run(codec.name, func(t *testing.T) { + compressed := codec.compress(t, body) + for _, test := range []struct { + name string + compressed []byte + length uint32 + }{ + {"advertised_length_too_large", compressed, uint32(len(body) + 1)}, + {"advertised_length_too_small", compressed, uint32(len(body) - 1)}, + {"truncated_stream", compressed[:len(compressed)-1], uint32(len(body))}, + {"extra_decompressed_byte", codec.compress(t, append(bytes.Clone(body), 0)), uint32(len(body))}, + {"trailing_compressed_bytes", append(bytes.Clone(compressed), 0, 1), uint32(len(body))}, + } { + t.Run(test.name, func(t *testing.T) { + hs := compressionTestHandshake(codec.algorithm) + got, err := hs.decompressCert(utlsCompressedCertificateMsg{ + algorithm: uint16(codec.algorithm), + uncompressedLength: test.length, + compressedCertificateMessage: test.compressed, + }) + if err == nil || got != nil { + t.Fatal("accepted an invalid compressed certificate message") + } + alertRecord := hs.c.sendBuf + if len(alertRecord) != recordHeaderLen+2 || alertRecord[0] != byte(recordTypeAlert) || + alertRecord[recordHeaderLen] != alertLevelError || alertRecord[recordHeaderLen+1] != byte(alertBadCertificate) { + t.Fatalf("sent alert %x, want bad_certificate", alertRecord) + } + }) + } + }) + } +} + +var certificateTestCodecs = []struct { + name string + algorithm CertCompressionAlgo + compress func(*testing.T, []byte) []byte +}{ + {"zlib", CertCompressionZlib, compressZlibTestCertificate}, + {"zstd", CertCompressionZstd, compressZstdTestCertificate}, +} + +func compressionTestHandshake(algorithm CertCompressionAlgo) *clientHandshakeStateTLS13 { + return &clientHandshakeStateTLS13{ + c: &Conn{buffering: true, config: &Config{}}, + uconn: &UConn{ + certCompressionAlgs: []CertCompressionAlgo{algorithm}, + }, + } +} + +func marshalCompressionTestCertificate(t *testing.T, certificate []byte) []byte { + t.Helper() + original, err := (&certificateMsgTLS13{certificate: Certificate{ + Certificate: [][]byte{certificate}, + }}).marshal() + if err != nil { + t.Fatal(err) + } + return original[4:] +} + +func compressZlibTestCertificate(t *testing.T, body []byte) []byte { + t.Helper() + var compressed bytes.Buffer + w := zlib.NewWriter(&compressed) + if _, err := w.Write(body); err != nil { + t.Fatal(err) + } + if err := w.Close(); err != nil { + t.Fatal(err) + } + return compressed.Bytes() +} + +func compressZstdTestCertificate(t *testing.T, body []byte) []byte { + t.Helper() + w, err := zstd.NewWriter(nil, zstd.WithEncoderConcurrency(1), zstd.WithEncoderCRC(true)) + if err != nil { + t.Fatal(err) + } + defer w.Close() + return w.EncodeAll(body, nil) +}