From 98c014078a4edc2297853fee60b2aa98d0817fcd Mon Sep 17 00:00:00 2001 From: Sarah Laplante Date: Mon, 16 Jun 2025 16:26:46 +0200 Subject: [PATCH 01/19] sni splitting framework --- transport/tlsfrag/split_sni.go | 27 ++++++++++++++ x/configurl/module.go | 1 + x/configurl/snifrag.go | 55 +++++++++++++++++++++++++++++ x/examples/smart-proxy/snifrag.yaml | 5 +++ 4 files changed, 88 insertions(+) create mode 100644 transport/tlsfrag/split_sni.go create mode 100644 x/configurl/snifrag.go create mode 100644 x/examples/smart-proxy/snifrag.yaml diff --git a/transport/tlsfrag/split_sni.go b/transport/tlsfrag/split_sni.go new file mode 100644 index 000000000..3a3651cb3 --- /dev/null +++ b/transport/tlsfrag/split_sni.go @@ -0,0 +1,27 @@ +// Copyright 2023 The Outline Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package tlsfrag + +// Split SNI implemented FragFunc. + +func MakeSplitSniFunc(sniSplit int) FragFunc { + // takes in an int, and returns a FragFunc which splits the on the sni + + fragFunc := func(clientHello []byte) int { + return sniSplit + } + + return fragFunc +} diff --git a/x/configurl/module.go b/x/configurl/module.go index dd86e221f..6fed210a8 100644 --- a/x/configurl/module.go +++ b/x/configurl/module.go @@ -62,6 +62,7 @@ func RegisterDefaultProviders(c *ProviderContainer) *ProviderContainer { registerTLSStreamDialer(&c.StreamDialers, "tls", c.StreamDialers.NewInstance) registerTLSFragStreamDialer(&c.StreamDialers, "tlsfrag", c.StreamDialers.NewInstance) + registerSNIFragStreamDialer(&c.StreamDialers, "snifrag", c.StreamDialers.NewInstance) registerWebsocketStreamDialer(&c.StreamDialers, "ws", c.StreamDialers.NewInstance) registerWebsocketPacketDialer(&c.PacketDialers, "ws", c.StreamDialers.NewInstance) diff --git a/x/configurl/snifrag.go b/x/configurl/snifrag.go new file mode 100644 index 000000000..f48bd0e82 --- /dev/null +++ b/x/configurl/snifrag.go @@ -0,0 +1,55 @@ +// Copyright 2024 The Outline Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package configurl + +import ( + "context" + "fmt" + "strconv" + + "github.com/Jigsaw-Code/outline-sdk/transport" + "github.com/Jigsaw-Code/outline-sdk/transport/tlsfrag" +) + +// TODO move this function into transport/tlsfrag/split_sni.go +func MakeSplitSniFunc(sniSplit int) tlsfrag.FragFunc { + // takes in an int, and returns a FragFunc which splits the on the sni + + fragFunc := func(clientHello []byte) int { + return sniSplit + } + + return fragFunc +} + +func registerSNIFragStreamDialer(r TypeRegistry[transport.StreamDialer], typeID string, newSD BuildFunc[transport.StreamDialer]) { + r.RegisterType(typeID, func(ctx context.Context, config *Config) (transport.StreamDialer, error) { + sd, err := newSD(ctx, config.BaseConfig) + if err != nil { + return nil, err + } + lenStr := config.URL.Opaque + sniSplit, err := strconv.Atoi(lenStr) + if err != nil { + return nil, fmt.Errorf("invalid snifrag option: %v. It should be in snifrag: format", lenStr) + } + // fragFunc returns the pre-configured SNI split value. + // The clientHello parameter is ignored as the split is fixed. + // TODO calculate manually + fragFunc := MakeSplitSniFunc(sniSplit) + + return tlsfrag.NewStreamDialerFunc(sd, fragFunc) + }) +} diff --git a/x/examples/smart-proxy/snifrag.yaml b/x/examples/smart-proxy/snifrag.yaml new file mode 100644 index 000000000..5ca24e388 --- /dev/null +++ b/x/examples/smart-proxy/snifrag.yaml @@ -0,0 +1,5 @@ +dns: + - system: {} + +tls: + - snifrag:10 \ No newline at end of file From 56c0cb4d113504eb2117991e1a37bdd2e890efd0 Mon Sep 17 00:00:00 2001 From: Sarah Laplante Date: Tue, 17 Jun 2025 14:15:25 +0200 Subject: [PATCH 02/19] failing test --- transport/tlsfrag/stream_dialer_test.go | 62 +++++++++++++++++++++++++ x/configurl/snifrag.go | 6 +-- 2 files changed, 64 insertions(+), 4 deletions(-) diff --git a/transport/tlsfrag/stream_dialer_test.go b/transport/tlsfrag/stream_dialer_test.go index 6f34c5a2c..0b1542cbc 100644 --- a/transport/tlsfrag/stream_dialer_test.go +++ b/transport/tlsfrag/stream_dialer_test.go @@ -142,6 +142,57 @@ func TestFixedLenStreamDialerSplitsClientHello(t *testing.T) { } } +// Make sure only the first Client Hello is splitted by a fixed length. +func TestSniSplittingStreamDialerSplitsSni(t *testing.T) { + hello := constructTLSRecord(t, layers.TLSHandshake, 0x0301, []byte{0x01, 0x00, 0x00, 0x03, 0xaa, 0xbb, 0xcc}) + cipher := constructTLSRecord(t, layers.TLSChangeCipherSpec, 0x0303, []byte{0x01}) + req1 := constructTLSRecord(t, layers.TLSApplicationData, 0x0303, []byte{0xff, 0xee, 0xdd, 0xcc, 0xbb, 0xaa, 0x99, 0x88}) + + cases := []struct { + msg string + original, splitted net.Buffers + splitLen int + }{ + { + msg: "split leading bytes", + original: net.Buffers{hello, cipher, req1, hello, cipher, req1}, + splitLen: 2, + splitted: net.Buffers{ + // Fragmented record header and payload are written as two packets by FixedLenWriter + constructTLSRecord(t, layers.TLSHandshake, 0x0301, []byte{0x01, 0x00}), + constructTLSRecord(t, layers.TLSHandshake, 0x0301, []byte{0x00, 0x03, 0xaa, 0xbb, 0xcc}), + cipher, req1, hello, cipher, req1, + }, + }, + { + msg: "split trailing bytes", + original: net.Buffers{hello, cipher, req1, hello, cipher, req1}, + splitLen: -2, + splitted: net.Buffers{ + // Fragmented record header and payload are written as two packets by FixedLenWriter + constructTLSRecord(t, layers.TLSHandshake, 0x0301, []byte{0x01, 0x00, 0x00, 0x03, 0xaa}), + constructTLSRecord(t, layers.TLSHandshake, 0x0301, []byte{0xbb, 0xcc}), + cipher, req1, hello, cipher, req1, + }, + }, + { + msg: "no split", + original: net.Buffers{hello, cipher, req1, hello, cipher, req1}, + splitLen: 0, + splitted: net.Buffers{hello, cipher, req1, hello, cipher, req1}, + }, + } + + for _, tc := range cases { + inner := &collectStreamDialer{} + conn := assertCanDialSniSplitFrag(t, inner, "ipinfo.io:443", tc.splitLen) + defer conn.Close() + + assertCanWriteAll(t, conn, tc.original) + require.Equal(t, tc.splitted, inner.bufs, tc.msg) + } +} + // Make sure the first Client Hello can be splitted multiple times. func TestNestedFixedLenStreamDialerSplitsClientHello(t *testing.T) { hello := constructTLSRecord(t, layers.TLSHandshake, 0x0301, []byte{ @@ -196,6 +247,17 @@ func assertCanDialFixedLenFrag(t *testing.T, inner transport.StreamDialer, raddr return conn } +func assertCanDialSniSplitFrag(t *testing.T, inner transport.StreamDialer, raddr string, splitLen int) transport.StreamConn { + splitSniFunc := MakeSplitSniFunc(splitLen) + d, err := NewStreamDialerFunc(inner, splitSniFunc) + require.NoError(t, err) + require.NotNil(t, d) + conn, err := d.DialStream(context.Background(), raddr) + require.NoError(t, err) + require.NotNil(t, conn) + return conn +} + func assertCanWriteAll(t *testing.T, w io.Writer, buf net.Buffers) { for _, p := range buf { n, err := w.Write(p) diff --git a/x/configurl/snifrag.go b/x/configurl/snifrag.go index f48bd0e82..b61ae83a4 100644 --- a/x/configurl/snifrag.go +++ b/x/configurl/snifrag.go @@ -23,6 +23,7 @@ import ( "github.com/Jigsaw-Code/outline-sdk/transport/tlsfrag" ) +// Writing this here since tlsfrag.MakeSplitSniFunc is not accessible without a release // TODO move this function into transport/tlsfrag/split_sni.go func MakeSplitSniFunc(sniSplit int) tlsfrag.FragFunc { // takes in an int, and returns a FragFunc which splits the on the sni @@ -45,11 +46,8 @@ func registerSNIFragStreamDialer(r TypeRegistry[transport.StreamDialer], typeID if err != nil { return nil, fmt.Errorf("invalid snifrag option: %v. It should be in snifrag: format", lenStr) } - // fragFunc returns the pre-configured SNI split value. - // The clientHello parameter is ignored as the split is fixed. - // TODO calculate manually - fragFunc := MakeSplitSniFunc(sniSplit) + fragFunc := MakeSplitSniFunc(sniSplit) return tlsfrag.NewStreamDialerFunc(sd, fragFunc) }) } From 2fd3f1932a9b23592f8ddb62808a6c105cea7f3c Mon Sep 17 00:00:00 2001 From: Sarah Laplante Date: Fri, 4 Jul 2025 13:12:06 +0200 Subject: [PATCH 03/19] positive value split test passing, sni splitting still unimplemented --- transport/tlsfrag/split_sni.go | 4 +++ transport/tlsfrag/stream_dialer.go | 3 +-- transport/tlsfrag/stream_dialer_test.go | 33 +++++++++++++++---------- transport/tlsfrag/writer.go | 4 +++ 4 files changed, 29 insertions(+), 15 deletions(-) diff --git a/transport/tlsfrag/split_sni.go b/transport/tlsfrag/split_sni.go index 3a3651cb3..9aff5f9e4 100644 --- a/transport/tlsfrag/split_sni.go +++ b/transport/tlsfrag/split_sni.go @@ -14,12 +14,16 @@ package tlsfrag +import "fmt" + // Split SNI implemented FragFunc. func MakeSplitSniFunc(sniSplit int) FragFunc { // takes in an int, and returns a FragFunc which splits the on the sni fragFunc := func(clientHello []byte) int { + fmt.Printf("clientHello: %v\n", clientHello) + fmt.Printf("sniSplit: %d\n", sniSplit) return sniSplit } diff --git a/transport/tlsfrag/stream_dialer.go b/transport/tlsfrag/stream_dialer.go index db398513b..f5afcdd9e 100644 --- a/transport/tlsfrag/stream_dialer.go +++ b/transport/tlsfrag/stream_dialer.go @@ -33,8 +33,7 @@ type FragFunc func(record []byte) (n int) // NewStreamDialerFunc creates a [transport.StreamDialer] that intercepts the initial [TLS Client Hello] // [handshake record] and splits it into two separate records before sending them. The split point is determined by the // callback function frag. The dialer then adds appropriate headers to each record and transmits them sequentially -// using the base dialer. Following the fragmented Client Hello, all subsequent data is passed through directly without -// modification. +// using the base dialer. Following the fragmented Client Hello, all subsequent data is passed through directly without modification. // // If you just want to split the record at a fixed position (e.g., always at the 5th byte or 2nd from the last byte), // use [NewFixedLenStreamDialer]. It consumes less resources and is more efficient. diff --git a/transport/tlsfrag/stream_dialer_test.go b/transport/tlsfrag/stream_dialer_test.go index 0b1542cbc..8922f8ebe 100644 --- a/transport/tlsfrag/stream_dialer_test.go +++ b/transport/tlsfrag/stream_dialer_test.go @@ -28,6 +28,10 @@ import ( "github.com/stretchr/testify/require" ) +func splitPayloadInHalf(payload []byte) int { + return len(payload) / 2 +} + // Make sure only the first Client Hello is splitted in half. func TestStreamDialerFuncSplitsClientHello(t *testing.T) { hello := constructTLSRecord(t, layers.TLSHandshake, 0x0301, []byte{0x01, 0x00, 0x00, 0x03, 0xaa, 0xbb, 0xcc}) @@ -35,7 +39,7 @@ func TestStreamDialerFuncSplitsClientHello(t *testing.T) { req1 := constructTLSRecord(t, layers.TLSApplicationData, 0x0303, []byte{0xff, 0xee, 0xdd, 0xcc, 0xbb, 0xaa, 0x99, 0x88}) inner := &collectStreamDialer{} - conn := assertCanDialFragFunc(t, inner, "ipinfo.io:443", func(payload []byte) int { return len(payload) / 2 }) + conn := assertCanDialFragFunc(t, inner, "ipinfo.io:443", splitPayloadInHalf) defer conn.Close() assertCanWriteAll(t, conn, net.Buffers{hello, cipher, req1, hello, cipher, req1}) @@ -78,7 +82,7 @@ func TestStreamDialerFuncDontSplitNonClientHello(t *testing.T) { for _, tc := range cases { inner := &collectStreamDialer{} - conn := assertCanDialFragFunc(t, inner, "ipinfo.io:443", func(payload []byte) int { return len(payload) / 2 }) + conn := assertCanDialFragFunc(t, inner, "ipinfo.io:443", splitPayloadInHalf) defer conn.Close() assertCanWriteAll(t, conn, net.Buffers{tc.pkt, cipher, req}) @@ -143,6 +147,7 @@ func TestFixedLenStreamDialerSplitsClientHello(t *testing.T) { } // Make sure only the first Client Hello is splitted by a fixed length. +// ------------------------------------------------------------------------ func TestSniSplittingStreamDialerSplitsSni(t *testing.T) { hello := constructTLSRecord(t, layers.TLSHandshake, 0x0301, []byte{0x01, 0x00, 0x00, 0x03, 0xaa, 0xbb, 0xcc}) cipher := constructTLSRecord(t, layers.TLSChangeCipherSpec, 0x0303, []byte{0x01}) @@ -156,31 +161,33 @@ func TestSniSplittingStreamDialerSplitsSni(t *testing.T) { { msg: "split leading bytes", original: net.Buffers{hello, cipher, req1, hello, cipher, req1}, - splitLen: 2, + splitLen: 3, splitted: net.Buffers{ - // Fragmented record header and payload are written as two packets by FixedLenWriter - constructTLSRecord(t, layers.TLSHandshake, 0x0301, []byte{0x01, 0x00}), - constructTLSRecord(t, layers.TLSHandshake, 0x0301, []byte{0x00, 0x03, 0xaa, 0xbb, 0xcc}), + // First two fragments will be merged in one single Write + append( + constructTLSRecord(t, layers.TLSHandshake, 0x0301, []byte{0x01, 0x00, 0x00}), + constructTLSRecord(t, layers.TLSHandshake, 0x0301, []byte{0x03, 0xaa, 0xbb, 0xcc})..., + ), cipher, req1, hello, cipher, req1, }, }, + { + msg: "no split", + original: net.Buffers{hello, cipher, req1, hello, cipher, req1}, + splitLen: 0, + splitted: net.Buffers{hello, cipher, req1, hello, cipher, req1}, + }, { msg: "split trailing bytes", original: net.Buffers{hello, cipher, req1, hello, cipher, req1}, splitLen: -2, splitted: net.Buffers{ - // Fragmented record header and payload are written as two packets by FixedLenWriter + // Fragmented record header and payload are written as two packets constructTLSRecord(t, layers.TLSHandshake, 0x0301, []byte{0x01, 0x00, 0x00, 0x03, 0xaa}), constructTLSRecord(t, layers.TLSHandshake, 0x0301, []byte{0xbb, 0xcc}), cipher, req1, hello, cipher, req1, }, }, - { - msg: "no split", - original: net.Buffers{hello, cipher, req1, hello, cipher, req1}, - splitLen: 0, - splitted: net.Buffers{hello, cipher, req1, hello, cipher, req1}, - }, } for _, tc := range cases { diff --git a/transport/tlsfrag/writer.go b/transport/tlsfrag/writer.go index 5e22865b2..adf43e1d8 100644 --- a/transport/tlsfrag/writer.go +++ b/transport/tlsfrag/writer.go @@ -17,6 +17,7 @@ package tlsfrag import ( "bytes" "errors" + "fmt" "io" ) @@ -152,6 +153,9 @@ func (w *clientHelloFragWriter) splitHelloBufToRecord() { original := w.helloBuf.Bytes() content := original[recordHeaderLen:] headLen := w.frag(content) + + fmt.Printf("headLen: %d\n", headLen) + if headLen <= 0 || headLen >= len(content) { w.copyHelloBufToRecord() return From e61da8e28cf7c3664821b31fd637f2adeed6638b Mon Sep 17 00:00:00 2001 From: Sarah Laplante Date: Fri, 4 Jul 2025 15:26:50 +0200 Subject: [PATCH 04/19] add test showing we won't split on anything but clienthellos --- transport/tlsfrag/split_sni.go | 18 +++++++-- transport/tlsfrag/stream_dialer_test.go | 51 +++++++++++++++++++------ 2 files changed, 55 insertions(+), 14 deletions(-) diff --git a/transport/tlsfrag/split_sni.go b/transport/tlsfrag/split_sni.go index 9aff5f9e4..7325448be 100644 --- a/transport/tlsfrag/split_sni.go +++ b/transport/tlsfrag/split_sni.go @@ -18,11 +18,23 @@ import "fmt" // Split SNI implemented FragFunc. +// splitSNI can be positive or negative +// positive splits forward in the sni, negative splits backward +// 2, example.com -> ex ample.com +// -5, example.com -> exam ple.com +// but must always return a positive index value in the payload + +// 00 00 00 18 00 16 00 00 13 ** 00 +// represents the SNI extension + sni + next message +// ** (with no 00) represents the domain name + +// https://datatracker.ietf.org/doc/html/rfc6066#section-3 + func MakeSplitSniFunc(sniSplit int) FragFunc { - // takes in an int, and returns a FragFunc which splits the on the sni + // takes in an int, and returns a FragFunc which splits on the sni - fragFunc := func(clientHello []byte) int { - fmt.Printf("clientHello: %v\n", clientHello) + fragFunc := func(payload []byte) int { + fmt.Printf("clientHello: %#x\n", payload) fmt.Printf("sniSplit: %d\n", sniSplit) return sniSplit } diff --git a/transport/tlsfrag/stream_dialer_test.go b/transport/tlsfrag/stream_dialer_test.go index 8922f8ebe..5abd39447 100644 --- a/transport/tlsfrag/stream_dialer_test.go +++ b/transport/tlsfrag/stream_dialer_test.go @@ -55,25 +55,54 @@ func TestStreamDialerFuncSplitsClientHello(t *testing.T) { // Make sure we don't split if the first packet is not a Client Hello. func TestStreamDialerFuncDontSplitNonClientHello(t *testing.T) { + splitSniFunc := MakeSplitSniFunc(5) + cases := []struct { - msg string - pkt []byte + msg string + pkt []byte + split FragFunc }{ + // Test splitPayloadInHalf + { + msg: "application data half split", + pkt: constructTLSRecord(t, layers.TLSApplicationData, 0x0303, []byte{0x01, 0x00, 0x00, 0x03, 0xdd, 0xee, 0xff}), + split: splitPayloadInHalf, + }, + { + msg: "cipher half split", + pkt: constructTLSRecord(t, layers.TLSChangeCipherSpec, 0x0303, []byte{0xff}), + split: splitPayloadInHalf, + }, + { + msg: "invalid version half split", + pkt: constructTLSRecord(t, layers.TLSHandshake, 0x0305, []byte{0x01, 0x00, 0x00, 0x03, 0xdd, 0xee, 0xff}), + split: splitPayloadInHalf, + }, + { + msg: "invalid length half split", + pkt: constructTLSRecord(t, layers.TLSHandshake, 0x0305, []byte{}), + split: splitPayloadInHalf, + }, + //Test splitSniFunc { - msg: "application data", - pkt: constructTLSRecord(t, layers.TLSApplicationData, 0x0303, []byte{0x01, 0x00, 0x00, 0x03, 0xdd, 0xee, 0xff}), + msg: "application data SNI split", + pkt: constructTLSRecord(t, layers.TLSApplicationData, 0x0303, []byte{0x01, 0x00, 0x00, 0x03, 0xdd, 0xee, 0xff}), + split: splitSniFunc, }, { - msg: "cipher", - pkt: constructTLSRecord(t, layers.TLSChangeCipherSpec, 0x0303, []byte{0xff}), + msg: "cipher SNI split", + pkt: constructTLSRecord(t, layers.TLSChangeCipherSpec, 0x0303, []byte{0xff}), + split: splitSniFunc, }, { - msg: "invalid version", - pkt: constructTLSRecord(t, layers.TLSHandshake, 0x0305, []byte{0x01, 0x00, 0x00, 0x03, 0xdd, 0xee, 0xff}), + msg: "invalid version SNI split", + pkt: constructTLSRecord(t, layers.TLSHandshake, 0x0305, []byte{0x01, 0x00, 0x00, 0x03, 0xdd, 0xee, 0xff}), + split: splitSniFunc, }, { - msg: "invalid length", - pkt: constructTLSRecord(t, layers.TLSHandshake, 0x0305, []byte{}), + msg: "invalid length SNI split", + pkt: constructTLSRecord(t, layers.TLSHandshake, 0x0305, []byte{}), + split: splitSniFunc, }, } @@ -82,7 +111,7 @@ func TestStreamDialerFuncDontSplitNonClientHello(t *testing.T) { for _, tc := range cases { inner := &collectStreamDialer{} - conn := assertCanDialFragFunc(t, inner, "ipinfo.io:443", splitPayloadInHalf) + conn := assertCanDialFragFunc(t, inner, "ipinfo.io:443", tc.split) defer conn.Close() assertCanWriteAll(t, conn, net.Buffers{tc.pkt, cipher, req}) From 7fc74f6fda125f286088da0747ff305bda0d4f64 Mon Sep 17 00:00:00 2001 From: Sarah Laplante Date: Mon, 7 Jul 2025 14:21:49 +0200 Subject: [PATCH 05/19] construct sni extension in tests --- transport/tlsfrag/split_sni.go | 34 +++++++++++++++++-------- transport/tlsfrag/stream_dialer_test.go | 22 +++++++++++++++- 2 files changed, 44 insertions(+), 12 deletions(-) diff --git a/transport/tlsfrag/split_sni.go b/transport/tlsfrag/split_sni.go index 7325448be..4e5a7c0d2 100644 --- a/transport/tlsfrag/split_sni.go +++ b/transport/tlsfrag/split_sni.go @@ -14,28 +14,40 @@ package tlsfrag -import "fmt" +import ( + "fmt" + "regexp" +) // Split SNI implemented FragFunc. -// splitSNI can be positive or negative +// sniSplit can be positive or negative // positive splits forward in the sni, negative splits backward // 2, example.com -> ex ample.com // -5, example.com -> exam ple.com -// but must always return a positive index value in the payload +// but must always return a positive index value in the clientHello +// if the sniSplit is longer than the length of the SNI then no split happens +// 15, example.com -> example.com -// 00 00 00 18 00 16 00 00 13 ** 00 -// represents the SNI extension + sni + next message -// ** (with no 00) represents the domain name +func MakeSplitSniFunc(sniSplit int) FragFunc { + // takes in an int, and returns a FragFunc which splits on the SNI -// https://datatracker.ietf.org/doc/html/rfc6066#section-3 + // 00 00 00 18 00 16 00 00 13 ** 00 + // represents the SNI extension + sni + next message + // ** (with no 00) represents the domain name + // https://datatracker.ietf.org/doc/html/rfc6066#section-3 + //sniHeader := []byte{0x00, 0x00, 0x00, 0x18, 0x00, 0x16, 0x00, 0x00, 0x13} -func MakeSplitSniFunc(sniSplit int) FragFunc { - // takes in an int, and returns a FragFunc which splits on the sni + pattern := `\x00\x00\x00\x18\x00\x16\x00\x00\x13` + re := regexp.MustCompile(pattern) - fragFunc := func(payload []byte) int { - fmt.Printf("clientHello: %#x\n", payload) + fragFunc := func(clientHello []byte) int { + fmt.Printf("clientHello: %#x\n", clientHello) fmt.Printf("sniSplit: %d\n", sniSplit) + + isMatch := re.Match(clientHello) + fmt.Printf("isMatch: %v\n", isMatch) + return sniSplit } diff --git a/transport/tlsfrag/stream_dialer_test.go b/transport/tlsfrag/stream_dialer_test.go index 5abd39447..90bcf1b45 100644 --- a/transport/tlsfrag/stream_dialer_test.go +++ b/transport/tlsfrag/stream_dialer_test.go @@ -15,6 +15,7 @@ package tlsfrag import ( + "bytes" "context" "errors" "io" @@ -178,7 +179,9 @@ func TestFixedLenStreamDialerSplitsClientHello(t *testing.T) { // Make sure only the first Client Hello is splitted by a fixed length. // ------------------------------------------------------------------------ func TestSniSplittingStreamDialerSplitsSni(t *testing.T) { - hello := constructTLSRecord(t, layers.TLSHandshake, 0x0301, []byte{0x01, 0x00, 0x00, 0x03, 0xaa, 0xbb, 0xcc}) + sniExtension := constructTLSSNIExtension(t, "example.com") + helloBody := bytes.Join([][]byte{{0x01, 0x00, 0x00}, {byte(len(sniExtension))}, sniExtension}, nil) + hello := constructTLSRecord(t, layers.TLSHandshake, 0x0301, helloBody) cipher := constructTLSRecord(t, layers.TLSChangeCipherSpec, 0x0303, []byte{0x01}) req1 := constructTLSRecord(t, layers.TLSApplicationData, 0x0303, []byte{0xff, 0xee, 0xdd, 0xcc, 0xbb, 0xaa, 0x99, 0x88}) @@ -322,6 +325,23 @@ func constructTLSRecord(t *testing.T, typ layers.TLSType, ver layers.TLSVersion, return buf.Bytes() } +// https://datatracker.ietf.org/doc/html/rfc6066#section-3 +func constructTLSSNIExtension(t *testing.T, domainName string) []byte { + sniExtensionHeader := []byte{0x00, 0x00, 0x00, 0x18, 0x00, 0x16, 0x00} + + // pad length of domain name to two bytes + //nameLength := uint16(len(domainName)) + //nameLengthBytes := append(byte(nameLength>>8), byte(nameLength)...) + + //fullExtension := append(header, nameLengthBytes, []byte(domainName)...) + + //return fullExtension + + nameLength := len(domainName) + result := append(sniExtensionHeader, byte(nameLength>>8), byte(nameLength)) + return append(result, domainName...) +} + // collectStreamDialer collects all writes to this stream dialer and append it to bufs type collectStreamDialer struct { bufs net.Buffers From 5a1d4cfdbd61cddf7e5eda5c3548992e07da2d84 Mon Sep 17 00:00:00 2001 From: Sarah Laplante Date: Mon, 7 Jul 2025 14:59:45 +0200 Subject: [PATCH 06/19] example sni split working --- transport/tlsfrag/split_sni.go | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/transport/tlsfrag/split_sni.go b/transport/tlsfrag/split_sni.go index 4e5a7c0d2..05e183753 100644 --- a/transport/tlsfrag/split_sni.go +++ b/transport/tlsfrag/split_sni.go @@ -15,6 +15,7 @@ package tlsfrag import ( + "encoding/binary" "fmt" "regexp" ) @@ -32,13 +33,13 @@ import ( func MakeSplitSniFunc(sniSplit int) FragFunc { // takes in an int, and returns a FragFunc which splits on the SNI - // 00 00 00 18 00 16 00 00 13 ** 00 - // represents the SNI extension + sni + next message + // 00 00 00 18 00 16 00 [00 0n] ** 00 + // represents the SNI extension + sni length + sni + next message // ** (with no 00) represents the domain name // https://datatracker.ietf.org/doc/html/rfc6066#section-3 - //sniHeader := []byte{0x00, 0x00, 0x00, 0x18, 0x00, 0x16, 0x00, 0x00, 0x13} + //sniHeader := []byte{0x00, 0x00, 0x00, 0x18, 0x00, 0x16, 0x00} - pattern := `\x00\x00\x00\x18\x00\x16\x00\x00\x13` + pattern := `\x00\x00\x00\x18\x00\x16\x00` re := regexp.MustCompile(pattern) fragFunc := func(clientHello []byte) int { @@ -46,9 +47,22 @@ func MakeSplitSniFunc(sniSplit int) FragFunc { fmt.Printf("sniSplit: %d\n", sniSplit) isMatch := re.Match(clientHello) + fmt.Printf("isMatch: %v\n", isMatch) - return sniSplit + sniExtensionIndex := re.FindIndex(clientHello)[0] + sniLengthBytes := clientHello[sniExtensionIndex+7 : sniExtensionIndex+9] + sniLength := int(binary.BigEndian.Uint16(sniLengthBytes)) + sniStartIndex := sniExtensionIndex + 9 + + fmt.Printf("sniLength: %v\n", sniLength) + fmt.Printf("sniStartIndex: %v\n", sniStartIndex) + + splitIndex := sniStartIndex + (sniSplit % sniLength) + + fmt.Printf("splitIndex: %v\n", splitIndex) + + return splitIndex } return fragFunc From f4a0f9aead46a02531a7d70cfeaeb9cedc0575b2 Mon Sep 17 00:00:00 2001 From: Sarah Laplante Date: Mon, 7 Jul 2025 15:12:30 +0200 Subject: [PATCH 07/19] not working in smart_dialer --- transport/tlsfrag/split_sni.go | 22 ++++++++++++---------- x/configurl/snifrag.go | 28 +++++++++++++++++++++++++++- 2 files changed, 39 insertions(+), 11 deletions(-) diff --git a/transport/tlsfrag/split_sni.go b/transport/tlsfrag/split_sni.go index 05e183753..e2183caff 100644 --- a/transport/tlsfrag/split_sni.go +++ b/transport/tlsfrag/split_sni.go @@ -47,22 +47,24 @@ func MakeSplitSniFunc(sniSplit int) FragFunc { fmt.Printf("sniSplit: %d\n", sniSplit) isMatch := re.Match(clientHello) - fmt.Printf("isMatch: %v\n", isMatch) - sniExtensionIndex := re.FindIndex(clientHello)[0] - sniLengthBytes := clientHello[sniExtensionIndex+7 : sniExtensionIndex+9] - sniLength := int(binary.BigEndian.Uint16(sniLengthBytes)) - sniStartIndex := sniExtensionIndex + 9 + if isMatch { + sniExtensionIndex := re.FindIndex(clientHello)[0] + sniLengthBytes := clientHello[sniExtensionIndex+7 : sniExtensionIndex+9] + sniLength := int(binary.BigEndian.Uint16(sniLengthBytes)) + sniStartIndex := sniExtensionIndex + 9 - fmt.Printf("sniLength: %v\n", sniLength) - fmt.Printf("sniStartIndex: %v\n", sniStartIndex) + fmt.Printf("sniLength: %v\n", sniLength) + fmt.Printf("sniStartIndex: %v\n", sniStartIndex) - splitIndex := sniStartIndex + (sniSplit % sniLength) + splitIndex := sniStartIndex + (sniSplit % sniLength) - fmt.Printf("splitIndex: %v\n", splitIndex) + fmt.Printf("splitIndex: %v\n", splitIndex) - return splitIndex + return splitIndex + } + return 0 } return fragFunc diff --git a/x/configurl/snifrag.go b/x/configurl/snifrag.go index b61ae83a4..da6bd5921 100644 --- a/x/configurl/snifrag.go +++ b/x/configurl/snifrag.go @@ -16,7 +16,9 @@ package configurl import ( "context" + "encoding/binary" "fmt" + "regexp" "strconv" "github.com/Jigsaw-Code/outline-sdk/transport" @@ -28,8 +30,32 @@ import ( func MakeSplitSniFunc(sniSplit int) tlsfrag.FragFunc { // takes in an int, and returns a FragFunc which splits the on the sni + pattern := `\x00\x00\x00\x18\x00\x16\x00` + re := regexp.MustCompile(pattern) + fragFunc := func(clientHello []byte) int { - return sniSplit + fmt.Printf("clientHello: %#x\n", clientHello) + fmt.Printf("sniSplit: %d\n", sniSplit) + + isMatch := re.Match(clientHello) + fmt.Printf("isMatch: %v\n", isMatch) + + if isMatch { + sniExtensionIndex := re.FindIndex(clientHello)[0] + sniLengthBytes := clientHello[sniExtensionIndex+7 : sniExtensionIndex+9] + sniLength := int(binary.BigEndian.Uint16(sniLengthBytes)) + sniStartIndex := sniExtensionIndex + 9 + + fmt.Printf("sniLength: %v\n", sniLength) + fmt.Printf("sniStartIndex: %v\n", sniStartIndex) + + splitIndex := sniStartIndex + (sniSplit % sniLength) + + fmt.Printf("splitIndex: %v\n", splitIndex) + + return splitIndex + } + return 0 } return fragFunc From 7eb0fd33a1e4b95d43c18cd87dedda8f25edd311 Mon Sep 17 00:00:00 2001 From: Sarah Laplante Date: Fri, 1 Aug 2025 10:17:04 +0200 Subject: [PATCH 08/19] checkpoint --- transport/tlsfrag/split_sni.go | 51 ++++++++ transport/tlsfrag/split_sni_test.go | 176 ++++++++++++++++++++++++++++ x/configurl/snifrag.go | 65 +++++++++- 3 files changed, 290 insertions(+), 2 deletions(-) create mode 100644 transport/tlsfrag/split_sni_test.go diff --git a/transport/tlsfrag/split_sni.go b/transport/tlsfrag/split_sni.go index e2183caff..0eedd53d3 100644 --- a/transport/tlsfrag/split_sni.go +++ b/transport/tlsfrag/split_sni.go @@ -30,6 +30,46 @@ import ( // if the sniSplit is longer than the length of the SNI then no split happens // 15, example.com -> example.com +// extract just the SNI extension from a client hello +func getSNIExtension(clientHello []byte) ([]byte, error) { + // 6 bytes client hello start + // 32 bytes randomness + // 1 byte session id + // 2 bytes cipher suite length + // n bytes cipher suites + // 2 bytes compression info + // 2 bytes extension length + + fmt.Printf("clientHello: %#x\n", clientHello) + + helloTypeLength := 1 + helloLengthLength := 3 + tlsVersionLength := 2 + clientRandomLength := 32 + + sessionDataIndex := helloTypeLength + helloLengthLength + tlsVersionLength + clientRandomLength + + sessionDataLength := int(clientHello[sessionDataIndex]) + + fmt.Printf("session: %#v %v, %#x\n", sessionDataIndex, sessionDataLength, clientHello[sessionDataIndex-1:sessionDataIndex]) + + cipherSuiteLengthIndex := sessionDataIndex + 1 + sessionDataLength + cipherSuiteLength := int(binary.BigEndian.Uint16(clientHello[cipherSuiteLengthIndex : cipherSuiteLengthIndex+2])) + + fmt.Printf("cipher: %#v %v, %#x\n", cipherSuiteLengthIndex, cipherSuiteLength, clientHello[cipherSuiteLengthIndex:cipherSuiteLengthIndex+2]) + + extensionLengthIndex := cipherSuiteLengthIndex + 2 + cipherSuiteLength + 2 + extensionsLength := int(binary.BigEndian.Uint16(clientHello[extensionLengthIndex : extensionLengthIndex+2])) + + fmt.Printf("extensions: %#v %v\n", extensionLengthIndex, extensionsLength) + + extensionContent := clientHello[extensionLengthIndex+2 : extensionLengthIndex+2+extensionsLength] + + fmt.Printf("extensionContent: %#v\n", extensionContent) + + return extensionContent, nil +} + func MakeSplitSniFunc(sniSplit int) FragFunc { // takes in an int, and returns a FragFunc which splits on the SNI @@ -39,13 +79,24 @@ func MakeSplitSniFunc(sniSplit int) FragFunc { // https://datatracker.ietf.org/doc/html/rfc6066#section-3 //sniHeader := []byte{0x00, 0x00, 0x00, 0x18, 0x00, 0x16, 0x00} + // a b c d e f g h i pattern := `\x00\x00\x00\x18\x00\x16\x00` + // a b = assigned value for server name extension + // c d = length of following server name extensino + // e f = length of first (and only) list entry + // g = entry type DNS hostname + // h i = length of hostname + re := regexp.MustCompile(pattern) fragFunc := func(clientHello []byte) int { fmt.Printf("clientHello: %#x\n", clientHello) fmt.Printf("sniSplit: %d\n", sniSplit) + ext, _ := getSNIExtension(clientHello) + + fmt.Printf("extensionContent: %#v\n", ext) + isMatch := re.Match(clientHello) fmt.Printf("isMatch: %v\n", isMatch) diff --git a/transport/tlsfrag/split_sni_test.go b/transport/tlsfrag/split_sni_test.go new file mode 100644 index 000000000..4f05488b3 --- /dev/null +++ b/transport/tlsfrag/split_sni_test.go @@ -0,0 +1,176 @@ +package tlsfrag + +import ( + "encoding/hex" + "reflect" + "testing" +) + +func TestGetSNIExtension(t *testing.T) { + + // client hello for example.com + exampleHexString := "010000f203036e6e645178e00c4caa6924d7e9a8cc9842c546e783835d3b58af3946184513e62081e4235a785224548d1b9996de3617e9622c13c2959d61f61f8bc53d500b7c94001ac02bc02fc02cc030cca9cca8c009c013c00ac0141301130213030100008f00000010000e00000b6578616d706c652e636f6d000b00020100ff010001000017000000120000000500050100000000000a000a0008001d001700180019000d001a0018080404030807080508060401050106010503060302010203002b00050403040303003300260024001d002026950802028351b26c54faa869d2378cb00740759e4d1d40ae3a76fb66730f2c" + decodedExampleBytes, _ := hex.DecodeString(exampleHexString) + + exampleSNIExtension := "00008f00000010000e00000b6578616d706c652e636f6d" + exampleExtensionBytes, _ := hex.DecodeString(exampleSNIExtension) + + // client hello for google.com + /* + googleHexString := "010001fc0303d28ec124ee698d44aa058538a94ce5f7c43eb7af4e192faf053ec359ac1532662011d050e6a40e34bee8891c68d643e22cb19597f91ed9706ff599fe882e0c25f60024130113021303c02bc02fc02cc030cca9cca8c009c013c00ac014009c009d002f0035000a0100018f0000000f000d00000a676f6f676c652e636f6d00170000ff01000100000a00080006001d00170018000b000201000010000e000c02683208687474702f312e31000d00140012040308040401050308050501080606010201003300260024001d0020a95d499fbfb3506b582ccb50cb960b930ff26000c630a025575beeb695690940002d00020101002b0009080304030303020301001500f6000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + decodedGoogleBytes, _ := hex.DecodeString(googleHexString) + */ + + tests := []struct { + name string + clientHello []byte + want []byte + wantErr bool + }{ + // TODO: Add test cases. + { + name: "example.com client hello", + clientHello: decodedExampleBytes, + want: exampleExtensionBytes, + wantErr: false, + }, + /* + { + name: "google.com client hello", + clientHello: decodedGoogleBytes, + want: []byte{}, + wantErr: true, + }, + */ + // Add more test cases to cover different scenarios + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := getSNIExtension(tt.clientHello) + if (err != nil) != tt.wantErr { + t.Errorf("getSNIExtension() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("getSNIExtension() = %v, want %v", got, tt.want) + } + }) + } +} + +func NoTestMakeSplitSniFunc(t *testing.T) { + // TODO: Implement test cases for MakeSplitSniFunc. + // This will likely involve creating different clientHello byte arrays + // and asserting that the split function behaves as expected. + // Consider testing various scenarios, including: + // - Valid client hellos with and without SNI extensions + // - Invalid client hellos + // - Client hellos with specific SNI values to verify the split logic + // Example test case: + // splitFunc := MakeSplitSniFunc() + // splitFunc(clientHello1) // Assert expected behavior + // splitFunc(clientHello2) // Assert expected behavior + + tests := []struct { + name string + sniSplit int + clientHello []byte + want int + }{ + // Placeholder test cases. Replace with actual data and expected results. + { + name: "Positive Split", + sniSplit: 2, + clientHello: []byte{}, // Replace with a valid client hello with SNI + want: 0, // Replace with the expected split index + }, + { + name: "Negative Split", + sniSplit: -3, + clientHello: []byte{}, // Replace with a valid client hello with SNI + want: 0, // Replace with the expected split index + }, + { + name: "No Split (Zero)", + sniSplit: 0, + clientHello: []byte{}, // Replace with a valid client hello with SNI + want: 0, // Replace with the expected split index (likely 0 for no split) + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + splitFunc := MakeSplitSniFunc(tt.sniSplit) + got := splitFunc(tt.clientHello) + if got != tt.want { + t.Errorf("MakeSplitSniFunc() with sniSplit %d = %v, want %v", tt.sniSplit, got, tt.want) + } + }) + } +} + +// meduza.io example through smart dialer setup +// clientHello: 0x010000f0030332d5f3ac710331ebf59761468a931eaf6a2b1b7adb61c44159eb9805e81eedaf20138638067ea909d8cbef5f19020e69037d689faee7ff54cc7ce5ce3f802daf52001ac02bc02fc02cc030cca9cca8c009c013c00ac0141301130213030100008d0000000e000c0000096d6564757a612e696f000b00020100ff010001000017000000120000000500050100000000000a000a0008001d001700180019000d001a0018080404030807080508060401050106010503060302010203002b00050403040303003300260024001d002048095487fcecdaf0152659eacc749a3783a260ba6477c26eb861720faa72e019 + +// meduza.io +// 6D 65 64 75 7A 61 2E 69 6F +// 6d6564757a612e696f +// surroundings +// 00008d0000000e000c0000096d 6564757a612e696f + +// -------------------------------------------- + +// example.com example through smart dialer setup +// clientHello: 0x010000f203036e6e645178e00c4caa6924d7e9a8cc9842c546e783835d3b58af3946184513e62081e4235a785224548d1b9996de3617e9622c13c2959d61f61f8bc53d500b7c94001ac02bc02fc02cc030cca9cca8c009c013c00ac0141301130213030100008f00000010000e00000b6578616d706c652e636f6d000b00020100ff010001000017000000120000000500050100000000000a000a0008001d001700180019000d001a0018080404030807080508060401050106010503060302010203002b00050403040303003300260024001d002026950802028351b26c54faa869d2378cb00740759e4d1d40ae3a76fb66730f2c + +/* +0x + +client hello type +01 + +length +00 00 f2 + +tls 1.3 +03 03 + +client random + session ID +6e6e645178e00c4caa6924d7e9a8cc9842c546e783835d3b58af3946184513e + + +62081e4235a785224548d1b9996de3617e9622c13c2959d61f61f8bc53d500b7c94001ac02bc02fc02cc030cca9cca8c009c013c + +cipher suites +00ac014130113021303 +0100 +008f + +// sni extension +0000 +0010 +000e +00 +000b +// example.com +6578616d706c652e636f6d + +000b00020100ff010001000017000000120000000500050100000000000a000a0008001d001700180019000d001a0018080404030807080508060401050106010503060302010203002b00050403040303003300260024001d002026950802028351b26c54faa869d2378cb00740759e4d1d40ae3a76fb66730f2c +*/ + +// example.com +// 65 78 61 6D 70 6C 65 2E 63 6F 6D +// 6578616d706c652e636f6d +// surroundings +// 00008f00000010000e00000b 6578616d706c652e636f6d + +// -------------------------------------------- + +// google.com through curl through smartdialer +// clientHello: 0x010001fc0303d28ec124ee698d44aa058538a94ce5f7c43eb7af4e192faf053ec359ac1532662011d050e6a40e34bee8891c68d643e22cb19597f91ed9706ff599fe882e0c25f60024130113021303c02bc02fc02cc030cca9cca8c009c013c00ac014009c009d002f0035000a0100018f0000000f000d00000a676f6f676c652e636f6d00170000ff01000100000a00080006001d00170018000b000201000010000e000c02683208687474702f312e31000d00140012040308040401050308050501080606010201003300260024001d0020a95d499fbfb3506b582ccb50cb960b930ff26000c630a025575beeb695690940002d00020101002b0009080304030303020301001500f6000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 + +// google.com in hex +// 67 6F 6F 67 6C 65 2E 63 6F 6D +// 676f6f676c652e636f6d +// surroundings +// 0000000f000d00000a 676f6f676c652e636f6d diff --git a/x/configurl/snifrag.go b/x/configurl/snifrag.go index da6bd5921..be2fe8282 100644 --- a/x/configurl/snifrag.go +++ b/x/configurl/snifrag.go @@ -26,17 +26,76 @@ import ( ) // Writing this here since tlsfrag.MakeSplitSniFunc is not accessible without a release -// TODO move this function into transport/tlsfrag/split_sni.go +// this allows direct testing through smart dialer +// DO NOT SUBMIT +// TODO delete this in favor of transport/tlsfrag/split_sni.go + +// -------------- COPY ZONE ----------------- + +// Split SNI implemented FragFunc. + +// sniSplit can be positive or negative +// positive splits forward in the sni, negative splits backward +// 2, example.com -> ex ample.com +// -5, example.com -> exam ple.com +// but must always return a positive index value in the clientHello +// if the sniSplit is longer than the length of the SNI then no split happens +// 15, example.com -> example.com + +// extract just the SNI extension from a client hello +func getSNIExtension(clientHello []byte) []byte { + // 6 bytes client hello start + // 32 bytes randomness + // 1 byte session id + // 2 bytes cipher suite length + // n bytes cipher suites + // 2 bytes compression info + // 2 bytes extension length + + cipherSuiteLengthIndex := 6 + 32 + 1 + cipherSuiteLength := int(binary.BigEndian.Uint16(clientHello[cipherSuiteLengthIndex : cipherSuiteLengthIndex+2])) + + fmt.Printf("cipher: %#v %v\n", cipherSuiteLengthIndex, cipherSuiteLength) + + extensionLengthIndex := cipherSuiteLengthIndex + 2 + cipherSuiteLength + 2 + extensionsLength := int(binary.BigEndian.Uint16(clientHello[extensionLengthIndex : extensionLengthIndex+2])) + + fmt.Printf("extensions: %#v %v\n", extensionLengthIndex, extensionsLength) + + extensionContent := clientHello[extensionLengthIndex+2 : extensionLengthIndex+2+extensionsLength] + + fmt.Printf("extensionContent: %#v\n", extensionContent) + + return extensionContent +} + func MakeSplitSniFunc(sniSplit int) tlsfrag.FragFunc { - // takes in an int, and returns a FragFunc which splits the on the sni + // takes in an int, and returns a FragFunc which splits on the SNI + + // 00 00 00 18 00 16 00 [00 0n] ** 00 + // represents the SNI extension + sni length + sni + next message + // ** (with no 00) represents the domain name + // https://datatracker.ietf.org/doc/html/rfc6066#section-3 + //sniHeader := []byte{0x00, 0x00, 0x00, 0x18, 0x00, 0x16, 0x00} + // a b c d e f g h i pattern := `\x00\x00\x00\x18\x00\x16\x00` + // a b = assigned value for server name extension + // c d = length of following server name extensino + // e f = length of first (and only) list entry + // g = entry type DNS hostname + // h i = length of hostname + re := regexp.MustCompile(pattern) fragFunc := func(clientHello []byte) int { fmt.Printf("clientHello: %#x\n", clientHello) fmt.Printf("sniSplit: %d\n", sniSplit) + ext := getSNIExtension(clientHello) + + fmt.Printf("extensionContent: %#v\n", ext) + isMatch := re.Match(clientHello) fmt.Printf("isMatch: %v\n", isMatch) @@ -61,6 +120,8 @@ func MakeSplitSniFunc(sniSplit int) tlsfrag.FragFunc { return fragFunc } +// -------------- COPY ZONE ----------------- + func registerSNIFragStreamDialer(r TypeRegistry[transport.StreamDialer], typeID string, newSD BuildFunc[transport.StreamDialer]) { r.RegisterType(typeID, func(ctx context.Context, config *Config) (transport.StreamDialer, error) { sd, err := newSD(ctx, config.BaseConfig) From 426246682177783fd94c4e3473131ac28c18b200 Mon Sep 17 00:00:00 2001 From: Sarah Laplante Date: Thu, 7 Aug 2025 15:07:27 +0200 Subject: [PATCH 09/19] TestFindFirstDomainIndex --- transport/tlsfrag/split_sni.go | 34 ++++++++++++++++++-- transport/tlsfrag/split_sni_test.go | 48 +++++++++++++++++++++++++++-- 2 files changed, 77 insertions(+), 5 deletions(-) diff --git a/transport/tlsfrag/split_sni.go b/transport/tlsfrag/split_sni.go index 0eedd53d3..e847f5554 100644 --- a/transport/tlsfrag/split_sni.go +++ b/transport/tlsfrag/split_sni.go @@ -63,11 +63,39 @@ func getSNIExtension(clientHello []byte) ([]byte, error) { fmt.Printf("extensions: %#v %v\n", extensionLengthIndex, extensionsLength) - extensionContent := clientHello[extensionLengthIndex+2 : extensionLengthIndex+2+extensionsLength] + allExtensionContent := clientHello[extensionLengthIndex+2 : extensionLengthIndex+2+extensionsLength] - fmt.Printf("extensionContent: %#v\n", extensionContent) + fmt.Printf("extensionContent: %#v\n", allExtensionContent) - return extensionContent, nil + //firstExtIdentifier = allExtensionContent[0:2] + //if firstExtIdentifier != []byte{0x00, 0x00} { + // return nil, Error("no SNI extension found in client hello") + //) + + // sniExtLength + + // sniExtension = + + //return extensionContent, nil + return nil, nil +} + +// The regex for a potential domain name. +const domainRegexPattern = `(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,6}|xn--[a-z0-9-]+(?:\.[a-z0-9-]+)*` + +// Pre-compile the regex once at the package level. +var domainRegex = regexp.MustCompile(domainRegexPattern) + +// findFirstDomainIndex takes a byte slice and returns the starting index of the +// first string that matches the domain regex. If no match is found, it returns -1. +func findFirstDomainIndex(data []byte) int { + // Use the pre-compiled regex directly. + matchIndexes := domainRegex.FindStringIndex(string(data)) + if matchIndexes == nil { + return -1 + } + + return matchIndexes[0] } func MakeSplitSniFunc(sniSplit int) FragFunc { diff --git a/transport/tlsfrag/split_sni_test.go b/transport/tlsfrag/split_sni_test.go index 4f05488b3..79deebf1f 100644 --- a/transport/tlsfrag/split_sni_test.go +++ b/transport/tlsfrag/split_sni_test.go @@ -6,6 +6,44 @@ import ( "testing" ) +func TestFindFirstDomainIndex(t *testing.T) { + // client hello for example.com + exampleHexString := "010000f203036e6e645178e00c4caa6924d7e9a8cc9842c546e783835d3b58af3946184513e62081e4235a785224548d1b9996de3617e9622c13c2959d61f61f8bc53d500b7c94001ac02bc02fc02cc030cca9cca8c009c013c00ac0141301130213030100008f00000010000e00000b6578616d706c652e636f6d000b00020100ff010001000017000000120000000500050100000000000a000a0008001d001700180019000d001a0018080404030807080508060401050106010503060302010203002b00050403040303003300260024001d002026950802028351b26c54faa869d2378cb00740759e4d1d40ae3a76fb66730f2c" + decodedExampleBytes, err := hex.DecodeString(exampleHexString) + if err != nil { + t.Fatalf("Failed to decode hex string: %v", err) + } + + tests := []struct { + name string + data []byte + want int + }{ + { + name: "example.com client hello", + data: decodedExampleBytes, + want: 112, + }, + { + name: "no domain", + data: []byte("some random bytes without a domain"), + want: -1, + }, + { + name: "empty data", + data: []byte{}, + want: -1, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := findFirstDomainIndex(tt.data); got != tt.want { + t.Errorf("findFirstDomainIndex() = %v, want %v", got, tt.want) + } + }) + } +} + func TestGetSNIExtension(t *testing.T) { // client hello for example.com @@ -109,21 +147,27 @@ func NoTestMakeSplitSniFunc(t *testing.T) { } } +/* + // meduza.io example through smart dialer setup -// clientHello: 0x010000f0030332d5f3ac710331ebf59761468a931eaf6a2b1b7adb61c44159eb9805e81eedaf20138638067ea909d8cbef5f19020e69037d689faee7ff54cc7ce5ce3f802daf52001ac02bc02fc02cc030cca9cca8c009c013c00ac0141301130213030100008d0000000e000c0000096d6564757a612e696f000b00020100ff010001000017000000120000000500050100000000000a000a0008001d001700180019000d001a0018080404030807080508060401050106010503060302010203002b00050403040303003300260024001d002048095487fcecdaf0152659eacc749a3783a260ba6477c26eb861720faa72e019 +// clientHello: 0x010000f0030332d5f3ac710331ebf59761468a931eaf6a2b1b7adb61c44159eb9805e81eedaf20138638067ea909d8cbef5f19020e69037d689faee7ff54cc7ce5ce3f802daf52001ac02bc02fc02cc030cca9cca8c009c013c00ac01413011302130301 +00008d0000000e000c0000096d6564757a612e696f000b00020100ff010001000017000000120000000500050100000000000a000a0008001d001700180019000d001a0018080404030807080508060401050106010503060302010203002b00050403040303003300260024001d002048095487fcecdaf0152659eacc749a3783a260ba6477c26eb861720faa72e019 // meduza.io // 6D 65 64 75 7A 61 2E 69 6F // 6d6564757a612e696f // surroundings // 00008d0000000e000c0000096d 6564757a612e696f +// +// alternative +// 00000016 // -------------------------------------------- // example.com example through smart dialer setup // clientHello: 0x010000f203036e6e645178e00c4caa6924d7e9a8cc9842c546e783835d3b58af3946184513e62081e4235a785224548d1b9996de3617e9622c13c2959d61f61f8bc53d500b7c94001ac02bc02fc02cc030cca9cca8c009c013c00ac0141301130213030100008f00000010000e00000b6578616d706c652e636f6d000b00020100ff010001000017000000120000000500050100000000000a000a0008001d001700180019000d001a0018080404030807080508060401050106010503060302010203002b00050403040303003300260024001d002026950802028351b26c54faa869d2378cb00740759e4d1d40ae3a76fb66730f2c -/* + 0x client hello type From 0ca0e3c92ea7616db051875609ba369dec1ea4ef Mon Sep 17 00:00:00 2001 From: Sarah Laplante Date: Thu, 7 Aug 2025 15:19:33 +0200 Subject: [PATCH 10/19] add google test --- transport/tlsfrag/split_sni_test.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/transport/tlsfrag/split_sni_test.go b/transport/tlsfrag/split_sni_test.go index 79deebf1f..c8357fc26 100644 --- a/transport/tlsfrag/split_sni_test.go +++ b/transport/tlsfrag/split_sni_test.go @@ -14,16 +14,28 @@ func TestFindFirstDomainIndex(t *testing.T) { t.Fatalf("Failed to decode hex string: %v", err) } + googleHexString := "010001fc0303d28ec124ee698d44aa058538a94ce5f7c43eb7af4e192faf053ec359ac1532662011d050e6a40e34bee8891c68d643e22cb19597f91ed9706ff599fe882e0c25f60024130113021303c02bc02fc02cc030cca9cca8c009c013c00ac014009c009d002f0035000a0100018f0000000f000d00000a676f6f676c652e636f6d00170000ff01000100000a00080006001d00170018000b000201000010000e000c02683208687474702f312e31000d00140012040308040401050308050501080606010201003300260024001d0020a95d499fbfb3506b582ccb50cb960b930ff26000c630a025575beeb695690940002d00020101002b0009080304030303020301001500f6000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + decodedGoogleBytes, err := hex.DecodeString(googleHexString) + if err != nil { + t.Fatalf("Failed to decode hex string: %v", err) + } + tests := []struct { name string data []byte want int }{ + // TODO haven't verified these test values { name: "example.com client hello", data: decodedExampleBytes, want: 112, }, + { + name: "google.com client hello", + data: decodedGoogleBytes, + want: 122, + }, { name: "no domain", data: []byte("some random bytes without a domain"), From 0acad77b650eeb29f2aff8675d74da820952d7f6 Mon Sep 17 00:00:00 2001 From: Sarah Laplante Date: Thu, 7 Aug 2025 19:07:23 +0200 Subject: [PATCH 11/19] checkpoint --- go.mod | 17 ++++--- go.sum | 13 +++++ transport/tlsfrag/split_sni.go | 77 ++++++++++++++++++++++++++++- transport/tlsfrag/split_sni_test.go | 2 +- 4 files changed, 100 insertions(+), 9 deletions(-) diff --git a/go.mod b/go.mod index 532d1c12d..c2ba0ade8 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,8 @@ module github.com/Jigsaw-Code/outline-sdk -go 1.20 +go 1.24.4 + +toolchain go1.24.6 require ( github.com/eycorsican/go-tun2socks v1.16.11 @@ -9,8 +11,8 @@ require ( github.com/shadowsocks/go-shadowsocks2 v0.1.5 github.com/stretchr/testify v1.8.4 github.com/things-go/go-socks5 v0.0.5 - golang.org/x/crypto v0.18.0 - golang.org/x/net v0.20.0 + golang.org/x/crypto v0.39.0 + golang.org/x/net v0.41.0 ) require ( @@ -32,13 +34,14 @@ require ( github.com/src-d/gcfg v1.4.0 // indirect github.com/xanzy/ssh-agent v0.2.1 // indirect go.opencensus.io v0.23.0 // indirect - golang.org/x/mod v0.8.0 // indirect - golang.org/x/sys v0.16.0 // indirect - golang.org/x/text v0.14.0 // indirect - golang.org/x/tools v0.6.0 // indirect + golang.org/x/mod v0.25.0 // indirect + golang.org/x/sys v0.33.0 // indirect + golang.org/x/text v0.26.0 // indirect + golang.org/x/tools v0.33.0 // indirect gopkg.in/src-d/go-billy.v4 v4.3.2 // indirect gopkg.in/src-d/go-git.v4 v4.13.1 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/klog/v2 v2.80.1 // indirect + src.agwa.name/tlshacks v0.0.0-20250628001001-c92050511ef4 // indirect ) diff --git a/go.sum b/go.sum index 178ce84ec..34fca52df 100644 --- a/go.sum +++ b/go.sum @@ -305,6 +305,8 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw= golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc= golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= +golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= +golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -344,6 +346,7 @@ golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91 golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.8.0 h1:LUYupSeNrTNCGzR/hVBk2NHZO4hXcVaW1k4Qx7rjPx8= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -392,6 +395,8 @@ golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws= golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo= golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= +golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= +golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -496,12 +501,15 @@ golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ= golang.org/x/term v0.16.0 h1:m+B6fahuftsE9qjo0VWp2FW0mB3MTJvR0BaMQrq0pmE= +golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -515,6 +523,7 @@ golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -574,6 +583,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.5.0/go.mod h1:N+Kgy78s5I24c24dU8OfWNEotWjutIs8SnJvn5IDq+k= golang.org/x/tools v0.6.0 h1:BOw41kyTf3PuCW1pVQf8+Cyg8pMlkYB1oo9iJ6D/lKM= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc= +golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -792,3 +803,5 @@ k8s.io/klog/v2 v2.80.1/go.mod h1:y1WjHnz7Dj687irZUWR/WLkLc5N1YHtjLdmgWjndZn0= rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= +src.agwa.name/tlshacks v0.0.0-20250628001001-c92050511ef4 h1:3fGGaToSriQNeOdh6Iko6UoWW8oFBgnXfMPp/g72OIY= +src.agwa.name/tlshacks v0.0.0-20250628001001-c92050511ef4/go.mod h1:c+kcKFr6y2/kWl2+Q8z/UuIWDPdlMuY038Id8tujfwA= diff --git a/transport/tlsfrag/split_sni.go b/transport/tlsfrag/split_sni.go index e847f5554..c0a4f94e9 100644 --- a/transport/tlsfrag/split_sni.go +++ b/transport/tlsfrag/split_sni.go @@ -15,9 +15,12 @@ package tlsfrag import ( + "bytes" "encoding/binary" "fmt" "regexp" + + "src.agwa.name/tlshacks" ) // Split SNI implemented FragFunc. @@ -98,7 +101,7 @@ func findFirstDomainIndex(data []byte) int { return matchIndexes[0] } -func MakeSplitSniFunc(sniSplit int) FragFunc { +func OldMakeSplitSniFunc(sniSplit int) FragFunc { // takes in an int, and returns a FragFunc which splits on the SNI // 00 00 00 18 00 16 00 [00 0n] ** 00 @@ -148,3 +151,75 @@ func MakeSplitSniFunc(sniSplit int) FragFunc { return fragFunc } + +func MakeSplitSniFunc(sniSplit int) FragFunc { + + fragFunc := func(clientHello []byte) int { + hello := tlshacks.UnmarshalClientHello(clientHello) + // Failed parse + if hello == nil { + return 0 + } + + var serverName string + // Find the Server Name Indication extension (type 0) + for _, ext := range hello.Extensions { + if ext.Type == 0 { // 0 is the type for server_name extension + // The content of the SNI extension is a ServerNameList. + // See RFC 6066, Section 3. + if len(ext.Data) < 2 { + break // Malformed extension, cannot parse. + } + // First 2 bytes: length of the server_name_list. + listLen := int(binary.BigEndian.Uint16(ext.Data)[0:2]) + if listLen != len(ext.Data)-2 { + break // Malformed extension. + } + + serverNameList := ext.Data[2:] + // We only care about the first name in the list. + if len(serverNameList) < 3 { + break // Malformed list. + } + nameType := serverNameList[0] + nameLen := int(binary.BigEndian.Uint16(serverNameList[1:3])) + if nameLen > len(serverNameList)-3 { + break // Malformed name entry. + } + if nameType == 0 { // 0 is for host_name + serverName = string(serverNameList[3 : 3+nameLen]) + } + // We found the SNI extension, so we can stop searching. + break + } + } + + if serverName == "" { + // No SNI, don't split. + return 0 + } + + sniIndex := bytes.Index(clientHello, []byte(serverName)) + if sniIndex == -1 { + // This should not happen if parsing was successful and ServerName is not empty. + // But as a safeguard, don't split. + return 0 + } + + sniLength := len(serverName) + splitOffset := sniSplit + if splitOffset < 0 { + // Handle negative split values, which count from the end of the SNI. + splitOffset = sniLength + splitOffset + } + + if splitOffset <= 0 || splitOffset >= sniLength { + // Invalid split point (outside the SNI), don't split. + return 0 + } + + return sniIndex + splitOffset + } + + return fragFunc +} diff --git a/transport/tlsfrag/split_sni_test.go b/transport/tlsfrag/split_sni_test.go index c8357fc26..350a8da8e 100644 --- a/transport/tlsfrag/split_sni_test.go +++ b/transport/tlsfrag/split_sni_test.go @@ -108,7 +108,7 @@ func TestGetSNIExtension(t *testing.T) { } } -func NoTestMakeSplitSniFunc(t *testing.T) { +func TestMakeSplitSniFunc(t *testing.T) { // TODO: Implement test cases for MakeSplitSniFunc. // This will likely involve creating different clientHello byte arrays // and asserting that the split function behaves as expected. From 6b30725baeed89d2e9bd6bc0abb7a92b824c37ac Mon Sep 17 00:00:00 2001 From: Sarah Laplante Date: Mon, 11 Aug 2025 14:46:38 +0200 Subject: [PATCH 12/19] parse dns extensions using tlshacks --- transport/tlsfrag/split_sni.go | 30 ++++++------------------------ 1 file changed, 6 insertions(+), 24 deletions(-) diff --git a/transport/tlsfrag/split_sni.go b/transport/tlsfrag/split_sni.go index c0a4f94e9..7f76d065f 100644 --- a/transport/tlsfrag/split_sni.go +++ b/transport/tlsfrag/split_sni.go @@ -164,30 +164,12 @@ func MakeSplitSniFunc(sniSplit int) FragFunc { var serverName string // Find the Server Name Indication extension (type 0) for _, ext := range hello.Extensions { - if ext.Type == 0 { // 0 is the type for server_name extension - // The content of the SNI extension is a ServerNameList. - // See RFC 6066, Section 3. - if len(ext.Data) < 2 { - break // Malformed extension, cannot parse. - } - // First 2 bytes: length of the server_name_list. - listLen := int(binary.BigEndian.Uint16(ext.Data)[0:2]) - if listLen != len(ext.Data)-2 { - break // Malformed extension. - } - - serverNameList := ext.Data[2:] - // We only care about the first name in the list. - if len(serverNameList) < 3 { - break // Malformed list. - } - nameType := serverNameList[0] - nameLen := int(binary.BigEndian.Uint16(serverNameList[1:3])) - if nameLen > len(serverNameList)-3 { - break // Malformed name entry. - } - if nameType == 0 { // 0 is for host_name - serverName = string(serverNameList[3 : 3+nameLen]) + if ext.Type == 0 { // 0 is the type for the ServerNameData extension + if sni, ok := ext.Data.(*tlshacks.ServerNameData); ok { + if len(sni.HostName) > 0 { + // We only care about the first hostname. + serverName = sni.HostName + } } // We found the SNI extension, so we can stop searching. break From f901d3ae1e2011d1b765ea5a3bfb285e2c0eebca Mon Sep 17 00:00:00 2001 From: Sarah Laplante Date: Mon, 11 Aug 2025 14:53:49 +0200 Subject: [PATCH 13/19] removing unused tests --- transport/tlsfrag/split_sni_test.go | 117 ++-------------------------- 1 file changed, 8 insertions(+), 109 deletions(-) diff --git a/transport/tlsfrag/split_sni_test.go b/transport/tlsfrag/split_sni_test.go index 350a8da8e..22b4cb90e 100644 --- a/transport/tlsfrag/split_sni_test.go +++ b/transport/tlsfrag/split_sni_test.go @@ -2,11 +2,11 @@ package tlsfrag import ( "encoding/hex" - "reflect" "testing" ) -func TestFindFirstDomainIndex(t *testing.T) { +func TestMakeSplitSniFunc(t *testing.T) { + // client hello for example.com exampleHexString := "010000f203036e6e645178e00c4caa6924d7e9a8cc9842c546e783835d3b58af3946184513e62081e4235a785224548d1b9996de3617e9622c13c2959d61f61f8bc53d500b7c94001ac02bc02fc02cc030cca9cca8c009c013c00ac0141301130213030100008f00000010000e00000b6578616d706c652e636f6d000b00020100ff010001000017000000120000000500050100000000000a000a0008001d001700180019000d001a0018080404030807080508060401050106010503060302010203002b00050403040303003300260024001d002026950802028351b26c54faa869d2378cb00740759e4d1d40ae3a76fb66730f2c" decodedExampleBytes, err := hex.DecodeString(exampleHexString) @@ -20,107 +20,6 @@ func TestFindFirstDomainIndex(t *testing.T) { t.Fatalf("Failed to decode hex string: %v", err) } - tests := []struct { - name string - data []byte - want int - }{ - // TODO haven't verified these test values - { - name: "example.com client hello", - data: decodedExampleBytes, - want: 112, - }, - { - name: "google.com client hello", - data: decodedGoogleBytes, - want: 122, - }, - { - name: "no domain", - data: []byte("some random bytes without a domain"), - want: -1, - }, - { - name: "empty data", - data: []byte{}, - want: -1, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := findFirstDomainIndex(tt.data); got != tt.want { - t.Errorf("findFirstDomainIndex() = %v, want %v", got, tt.want) - } - }) - } -} - -func TestGetSNIExtension(t *testing.T) { - - // client hello for example.com - exampleHexString := "010000f203036e6e645178e00c4caa6924d7e9a8cc9842c546e783835d3b58af3946184513e62081e4235a785224548d1b9996de3617e9622c13c2959d61f61f8bc53d500b7c94001ac02bc02fc02cc030cca9cca8c009c013c00ac0141301130213030100008f00000010000e00000b6578616d706c652e636f6d000b00020100ff010001000017000000120000000500050100000000000a000a0008001d001700180019000d001a0018080404030807080508060401050106010503060302010203002b00050403040303003300260024001d002026950802028351b26c54faa869d2378cb00740759e4d1d40ae3a76fb66730f2c" - decodedExampleBytes, _ := hex.DecodeString(exampleHexString) - - exampleSNIExtension := "00008f00000010000e00000b6578616d706c652e636f6d" - exampleExtensionBytes, _ := hex.DecodeString(exampleSNIExtension) - - // client hello for google.com - /* - googleHexString := "010001fc0303d28ec124ee698d44aa058538a94ce5f7c43eb7af4e192faf053ec359ac1532662011d050e6a40e34bee8891c68d643e22cb19597f91ed9706ff599fe882e0c25f60024130113021303c02bc02fc02cc030cca9cca8c009c013c00ac014009c009d002f0035000a0100018f0000000f000d00000a676f6f676c652e636f6d00170000ff01000100000a00080006001d00170018000b000201000010000e000c02683208687474702f312e31000d00140012040308040401050308050501080606010201003300260024001d0020a95d499fbfb3506b582ccb50cb960b930ff26000c630a025575beeb695690940002d00020101002b0009080304030303020301001500f6000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" - decodedGoogleBytes, _ := hex.DecodeString(googleHexString) - */ - - tests := []struct { - name string - clientHello []byte - want []byte - wantErr bool - }{ - // TODO: Add test cases. - { - name: "example.com client hello", - clientHello: decodedExampleBytes, - want: exampleExtensionBytes, - wantErr: false, - }, - /* - { - name: "google.com client hello", - clientHello: decodedGoogleBytes, - want: []byte{}, - wantErr: true, - }, - */ - // Add more test cases to cover different scenarios - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := getSNIExtension(tt.clientHello) - if (err != nil) != tt.wantErr { - t.Errorf("getSNIExtension() error = %v, wantErr %v", err, tt.wantErr) - return - } - if !reflect.DeepEqual(got, tt.want) { - t.Errorf("getSNIExtension() = %v, want %v", got, tt.want) - } - }) - } -} - -func TestMakeSplitSniFunc(t *testing.T) { - // TODO: Implement test cases for MakeSplitSniFunc. - // This will likely involve creating different clientHello byte arrays - // and asserting that the split function behaves as expected. - // Consider testing various scenarios, including: - // - Valid client hellos with and without SNI extensions - // - Invalid client hellos - // - Client hellos with specific SNI values to verify the split logic - // Example test case: - // splitFunc := MakeSplitSniFunc() - // splitFunc(clientHello1) // Assert expected behavior - // splitFunc(clientHello2) // Assert expected behavior - tests := []struct { name string sniSplit int @@ -131,20 +30,20 @@ func TestMakeSplitSniFunc(t *testing.T) { { name: "Positive Split", sniSplit: 2, - clientHello: []byte{}, // Replace with a valid client hello with SNI - want: 0, // Replace with the expected split index + clientHello: decodedExampleBytes, + want: 114, }, { name: "Negative Split", sniSplit: -3, - clientHello: []byte{}, // Replace with a valid client hello with SNI - want: 0, // Replace with the expected split index + clientHello: decodedGoogleBytes, + want: 129, }, { name: "No Split (Zero)", sniSplit: 0, - clientHello: []byte{}, // Replace with a valid client hello with SNI - want: 0, // Replace with the expected split index (likely 0 for no split) + clientHello: []byte{}, // Empty client hello + want: 0, }, } From 2427bc7605a9eead70757eacc9d70683390503dc Mon Sep 17 00:00:00 2001 From: Sarah Laplante Date: Mon, 11 Aug 2025 15:40:05 +0200 Subject: [PATCH 14/19] tested split working in smart dialer --- x/configurl/snifrag.go | 119 +++++++++++++++-------------------------- x/go.mod | 22 ++++---- x/go.sum | 13 +++++ 3 files changed, 69 insertions(+), 85 deletions(-) diff --git a/x/configurl/snifrag.go b/x/configurl/snifrag.go index be2fe8282..67db1aa1c 100644 --- a/x/configurl/snifrag.go +++ b/x/configurl/snifrag.go @@ -15,12 +15,13 @@ package configurl import ( + "bytes" "context" - "encoding/binary" "fmt" - "regexp" "strconv" + "src.agwa.name/tlshacks" + "github.com/Jigsaw-Code/outline-sdk/transport" "github.com/Jigsaw-Code/outline-sdk/transport/tlsfrag" ) @@ -32,89 +33,55 @@ import ( // -------------- COPY ZONE ----------------- -// Split SNI implemented FragFunc. - -// sniSplit can be positive or negative -// positive splits forward in the sni, negative splits backward -// 2, example.com -> ex ample.com -// -5, example.com -> exam ple.com -// but must always return a positive index value in the clientHello -// if the sniSplit is longer than the length of the SNI then no split happens -// 15, example.com -> example.com - -// extract just the SNI extension from a client hello -func getSNIExtension(clientHello []byte) []byte { - // 6 bytes client hello start - // 32 bytes randomness - // 1 byte session id - // 2 bytes cipher suite length - // n bytes cipher suites - // 2 bytes compression info - // 2 bytes extension length - - cipherSuiteLengthIndex := 6 + 32 + 1 - cipherSuiteLength := int(binary.BigEndian.Uint16(clientHello[cipherSuiteLengthIndex : cipherSuiteLengthIndex+2])) - - fmt.Printf("cipher: %#v %v\n", cipherSuiteLengthIndex, cipherSuiteLength) - - extensionLengthIndex := cipherSuiteLengthIndex + 2 + cipherSuiteLength + 2 - extensionsLength := int(binary.BigEndian.Uint16(clientHello[extensionLengthIndex : extensionLengthIndex+2])) - - fmt.Printf("extensions: %#v %v\n", extensionLengthIndex, extensionsLength) - - extensionContent := clientHello[extensionLengthIndex+2 : extensionLengthIndex+2+extensionsLength] - - fmt.Printf("extensionContent: %#v\n", extensionContent) - - return extensionContent -} - func MakeSplitSniFunc(sniSplit int) tlsfrag.FragFunc { - // takes in an int, and returns a FragFunc which splits on the SNI - - // 00 00 00 18 00 16 00 [00 0n] ** 00 - // represents the SNI extension + sni length + sni + next message - // ** (with no 00) represents the domain name - // https://datatracker.ietf.org/doc/html/rfc6066#section-3 - //sniHeader := []byte{0x00, 0x00, 0x00, 0x18, 0x00, 0x16, 0x00} - - // a b c d e f g h i - pattern := `\x00\x00\x00\x18\x00\x16\x00` - // a b = assigned value for server name extension - // c d = length of following server name extensino - // e f = length of first (and only) list entry - // g = entry type DNS hostname - // h i = length of hostname - - re := regexp.MustCompile(pattern) fragFunc := func(clientHello []byte) int { - fmt.Printf("clientHello: %#x\n", clientHello) - fmt.Printf("sniSplit: %d\n", sniSplit) - - ext := getSNIExtension(clientHello) - - fmt.Printf("extensionContent: %#v\n", ext) - - isMatch := re.Match(clientHello) - fmt.Printf("isMatch: %v\n", isMatch) + hello := tlshacks.UnmarshalClientHello(clientHello) + // Failed parse + if hello == nil { + return 0 + } - if isMatch { - sniExtensionIndex := re.FindIndex(clientHello)[0] - sniLengthBytes := clientHello[sniExtensionIndex+7 : sniExtensionIndex+9] - sniLength := int(binary.BigEndian.Uint16(sniLengthBytes)) - sniStartIndex := sniExtensionIndex + 9 + var serverName string + // Find the Server Name Indication extension (type 0) + for _, ext := range hello.Extensions { + if ext.Type == 0 { // 0 is the type for the ServerNameData extension + if sni, ok := ext.Data.(*tlshacks.ServerNameData); ok { + if len(sni.HostName) > 0 { + // We only care about the first hostname. + serverName = sni.HostName + } + } + // We found the SNI extension, so we can stop searching. + break + } + } - fmt.Printf("sniLength: %v\n", sniLength) - fmt.Printf("sniStartIndex: %v\n", sniStartIndex) + if serverName == "" { + // No SNI, don't split. + return 0 + } - splitIndex := sniStartIndex + (sniSplit % sniLength) + sniIndex := bytes.Index(clientHello, []byte(serverName)) + if sniIndex == -1 { + // This should not happen if parsing was successful and ServerName is not empty. + // But as a safeguard, don't split. + return 0 + } - fmt.Printf("splitIndex: %v\n", splitIndex) + sniLength := len(serverName) + splitOffset := sniSplit + if splitOffset < 0 { + // Handle negative split values, which count from the end of the SNI. + splitOffset = sniLength + splitOffset + } - return splitIndex + if splitOffset <= 0 || splitOffset >= sniLength { + // Invalid split point (outside the SNI), don't split. + return 0 } - return 0 + + return sniIndex + splitOffset } return fragFunc diff --git a/x/go.mod b/x/go.mod index e63b0741f..d1a6ccf79 100644 --- a/x/go.mod +++ b/x/go.mod @@ -1,6 +1,8 @@ module github.com/Jigsaw-Code/outline-sdk/x -go 1.23.0 +go 1.24.4 + +toolchain go1.24.6 require ( github.com/Jigsaw-Code/outline-sdk v0.0.20 @@ -15,11 +17,13 @@ require ( github.com/stretchr/testify v1.9.0 github.com/vishvananda/netlink v1.2.1-beta.2 golang.org/x/mobile v0.0.0-20240520174638-fa72addaaa1b - golang.org/x/net v0.36.0 - golang.org/x/sys v0.30.0 - golang.org/x/term v0.29.0 + golang.org/x/net v0.41.0 + golang.org/x/sys v0.33.0 + golang.org/x/term v0.32.0 ) +require src.agwa.name/tlshacks v0.0.0-20250628001001-c92050511ef4 + require ( filippo.io/bigmod v0.0.1 // indirect filippo.io/edwards25519 v1.1.0 // indirect @@ -109,13 +113,13 @@ require ( go.uber.org/mock v0.4.0 // indirect go4.org/mem v0.0.0-20220726221520-4f986261bf13 // indirect go4.org/netipx v0.0.0-20230824141953-6213f710f925 // indirect - golang.org/x/crypto v0.35.0 // indirect + golang.org/x/crypto v0.39.0 // indirect golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 // indirect - golang.org/x/mod v0.17.0 // indirect - golang.org/x/sync v0.11.0 // indirect - golang.org/x/text v0.22.0 // indirect + golang.org/x/mod v0.25.0 // indirect + golang.org/x/sync v0.15.0 // indirect + golang.org/x/text v0.26.0 // indirect golang.org/x/time v0.5.0 // indirect - golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect + golang.org/x/tools v0.33.0 // indirect golang.zx2c4.com/wireguard v0.0.0-20230325221338-052af4a8072b // indirect golang.zx2c4.com/wireguard/windows v0.5.3 // indirect google.golang.org/protobuf v1.33.0 // indirect diff --git a/x/go.sum b/x/go.sum index ca989d71d..6be577a8d 100644 --- a/x/go.sum +++ b/x/go.sum @@ -315,6 +315,8 @@ golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98y golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs= golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ= +golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= +golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM= golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc= golang.org/x/mobile v0.0.0-20240520174638-fa72addaaa1b h1:WX7nnnLfCEXg+FmdYZPai2XuP3VqCP1HZVMST0n9DF0= @@ -323,6 +325,7 @@ golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91 golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20191021144547-ec77196f6094/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -337,12 +340,15 @@ golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI= golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= golang.org/x/net v0.36.0 h1:vWF2fRbw4qslQsQzgFqZff+BItCvGFQqKzKIzx1rmoA= golang.org/x/net v0.36.0/go.mod h1:bFmbeoIPfrw4sMHNhb4J9f6+tPziuGjq7Jk/38fxi1I= +golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= +golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -366,6 +372,8 @@ golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -378,6 +386,7 @@ golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU= golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU= golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s= +golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= @@ -389,6 +398,7 @@ golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -397,6 +407,7 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.zx2c4.com/wireguard v0.0.0-20230325221338-052af4a8072b h1:J1CaxgLerRR5lgx3wnr6L04cJFbWoceSK9JWBdglINo= @@ -416,5 +427,7 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +src.agwa.name/tlshacks v0.0.0-20250628001001-c92050511ef4 h1:3fGGaToSriQNeOdh6Iko6UoWW8oFBgnXfMPp/g72OIY= +src.agwa.name/tlshacks v0.0.0-20250628001001-c92050511ef4/go.mod h1:c+kcKFr6y2/kWl2+Q8z/UuIWDPdlMuY038Id8tujfwA= tailscale.com v1.58.2 h1:5trkhh/fpUn7f6TUcGUQYJ0GokdNNfNrjh9ONJhoc5A= tailscale.com v1.58.2/go.mod h1:faWR8XaXemnSKCDjHC7SAQzaagkUjA5x4jlLWiwxtuk= From 564d80b398d912c7230486067be3aacd6562277e Mon Sep 17 00:00:00 2001 From: Sarah Laplante Date: Mon, 11 Aug 2025 16:00:07 +0200 Subject: [PATCH 15/19] some modifications to mod cases --- transport/tlsfrag/split_sni.go | 146 ++-------------------------- transport/tlsfrag/split_sni_test.go | 14 ++- x/examples/smart-proxy/snifrag.yaml | 2 +- 3 files changed, 17 insertions(+), 145 deletions(-) diff --git a/transport/tlsfrag/split_sni.go b/transport/tlsfrag/split_sni.go index 7f76d065f..fad7a2609 100644 --- a/transport/tlsfrag/split_sni.go +++ b/transport/tlsfrag/split_sni.go @@ -16,143 +16,17 @@ package tlsfrag import ( "bytes" - "encoding/binary" - "fmt" - "regexp" "src.agwa.name/tlshacks" ) -// Split SNI implemented FragFunc. - // sniSplit can be positive or negative // positive splits forward in the sni, negative splits backward // 2, example.com -> ex ample.com // -5, example.com -> exam ple.com // but must always return a positive index value in the clientHello -// if the sniSplit is longer than the length of the SNI then no split happens -// 15, example.com -> example.com - -// extract just the SNI extension from a client hello -func getSNIExtension(clientHello []byte) ([]byte, error) { - // 6 bytes client hello start - // 32 bytes randomness - // 1 byte session id - // 2 bytes cipher suite length - // n bytes cipher suites - // 2 bytes compression info - // 2 bytes extension length - - fmt.Printf("clientHello: %#x\n", clientHello) - - helloTypeLength := 1 - helloLengthLength := 3 - tlsVersionLength := 2 - clientRandomLength := 32 - - sessionDataIndex := helloTypeLength + helloLengthLength + tlsVersionLength + clientRandomLength - - sessionDataLength := int(clientHello[sessionDataIndex]) - - fmt.Printf("session: %#v %v, %#x\n", sessionDataIndex, sessionDataLength, clientHello[sessionDataIndex-1:sessionDataIndex]) - - cipherSuiteLengthIndex := sessionDataIndex + 1 + sessionDataLength - cipherSuiteLength := int(binary.BigEndian.Uint16(clientHello[cipherSuiteLengthIndex : cipherSuiteLengthIndex+2])) - - fmt.Printf("cipher: %#v %v, %#x\n", cipherSuiteLengthIndex, cipherSuiteLength, clientHello[cipherSuiteLengthIndex:cipherSuiteLengthIndex+2]) - - extensionLengthIndex := cipherSuiteLengthIndex + 2 + cipherSuiteLength + 2 - extensionsLength := int(binary.BigEndian.Uint16(clientHello[extensionLengthIndex : extensionLengthIndex+2])) - - fmt.Printf("extensions: %#v %v\n", extensionLengthIndex, extensionsLength) - - allExtensionContent := clientHello[extensionLengthIndex+2 : extensionLengthIndex+2+extensionsLength] - - fmt.Printf("extensionContent: %#v\n", allExtensionContent) - - //firstExtIdentifier = allExtensionContent[0:2] - //if firstExtIdentifier != []byte{0x00, 0x00} { - // return nil, Error("no SNI extension found in client hello") - //) - - // sniExtLength - - // sniExtension = - - //return extensionContent, nil - return nil, nil -} - -// The regex for a potential domain name. -const domainRegexPattern = `(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,6}|xn--[a-z0-9-]+(?:\.[a-z0-9-]+)*` - -// Pre-compile the regex once at the package level. -var domainRegex = regexp.MustCompile(domainRegexPattern) - -// findFirstDomainIndex takes a byte slice and returns the starting index of the -// first string that matches the domain regex. If no match is found, it returns -1. -func findFirstDomainIndex(data []byte) int { - // Use the pre-compiled regex directly. - matchIndexes := domainRegex.FindStringIndex(string(data)) - if matchIndexes == nil { - return -1 - } - - return matchIndexes[0] -} - -func OldMakeSplitSniFunc(sniSplit int) FragFunc { - // takes in an int, and returns a FragFunc which splits on the SNI - - // 00 00 00 18 00 16 00 [00 0n] ** 00 - // represents the SNI extension + sni length + sni + next message - // ** (with no 00) represents the domain name - // https://datatracker.ietf.org/doc/html/rfc6066#section-3 - //sniHeader := []byte{0x00, 0x00, 0x00, 0x18, 0x00, 0x16, 0x00} - // a b c d e f g h i - pattern := `\x00\x00\x00\x18\x00\x16\x00` - // a b = assigned value for server name extension - // c d = length of following server name extensino - // e f = length of first (and only) list entry - // g = entry type DNS hostname - // h i = length of hostname - - re := regexp.MustCompile(pattern) - - fragFunc := func(clientHello []byte) int { - fmt.Printf("clientHello: %#x\n", clientHello) - fmt.Printf("sniSplit: %d\n", sniSplit) - - ext, _ := getSNIExtension(clientHello) - - fmt.Printf("extensionContent: %#v\n", ext) - - isMatch := re.Match(clientHello) - fmt.Printf("isMatch: %v\n", isMatch) - - if isMatch { - sniExtensionIndex := re.FindIndex(clientHello)[0] - sniLengthBytes := clientHello[sniExtensionIndex+7 : sniExtensionIndex+9] - sniLength := int(binary.BigEndian.Uint16(sniLengthBytes)) - sniStartIndex := sniExtensionIndex + 9 - - fmt.Printf("sniLength: %v\n", sniLength) - fmt.Printf("sniStartIndex: %v\n", sniStartIndex) - - splitIndex := sniStartIndex + (sniSplit % sniLength) - - fmt.Printf("splitIndex: %v\n", splitIndex) - - return splitIndex - } - return 0 - } - - return fragFunc -} - -func MakeSplitSniFunc(sniSplit int) FragFunc { +func MakeSplitSniFunc(sniSplitOffset int) FragFunc { fragFunc := func(clientHello []byte) int { hello := tlshacks.UnmarshalClientHello(clientHello) @@ -180,6 +54,10 @@ func MakeSplitSniFunc(sniSplit int) FragFunc { // No SNI, don't split. return 0 } + sniLength := len(serverName) + + // Adjust sniSplits that are negative or longer than sniLength to the correct value + sniSplitOffset = sniSplitOffset % sniLength sniIndex := bytes.Index(clientHello, []byte(serverName)) if sniIndex == -1 { @@ -188,19 +66,7 @@ func MakeSplitSniFunc(sniSplit int) FragFunc { return 0 } - sniLength := len(serverName) - splitOffset := sniSplit - if splitOffset < 0 { - // Handle negative split values, which count from the end of the SNI. - splitOffset = sniLength + splitOffset - } - - if splitOffset <= 0 || splitOffset >= sniLength { - // Invalid split point (outside the SNI), don't split. - return 0 - } - - return sniIndex + splitOffset + return sniIndex + sniSplitOffset } return fragFunc diff --git a/transport/tlsfrag/split_sni_test.go b/transport/tlsfrag/split_sni_test.go index 22b4cb90e..f47a66e4c 100644 --- a/transport/tlsfrag/split_sni_test.go +++ b/transport/tlsfrag/split_sni_test.go @@ -14,6 +14,7 @@ func TestMakeSplitSniFunc(t *testing.T) { t.Fatalf("Failed to decode hex string: %v", err) } + // client hello for google.com googleHexString := "010001fc0303d28ec124ee698d44aa058538a94ce5f7c43eb7af4e192faf053ec359ac1532662011d050e6a40e34bee8891c68d643e22cb19597f91ed9706ff599fe882e0c25f60024130113021303c02bc02fc02cc030cca9cca8c009c013c00ac014009c009d002f0035000a0100018f0000000f000d00000a676f6f676c652e636f6d00170000ff01000100000a00080006001d00170018000b000201000010000e000c02683208687474702f312e31000d00140012040308040401050308050501080606010201003300260024001d0020a95d499fbfb3506b582ccb50cb960b930ff26000c630a025575beeb695690940002d00020101002b0009080304030303020301001500f6000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" decodedGoogleBytes, err := hex.DecodeString(googleHexString) if err != nil { @@ -26,22 +27,27 @@ func TestMakeSplitSniFunc(t *testing.T) { clientHello []byte want int }{ - // Placeholder test cases. Replace with actual data and expected results. { name: "Positive Split", sniSplit: 2, clientHello: decodedExampleBytes, want: 114, }, + { + name: "Split longer than SNI", + sniSplit: 15, + clientHello: decodedExampleBytes, + want: 116, + }, { name: "Negative Split", sniSplit: -3, clientHello: decodedGoogleBytes, - want: 129, + want: 119, }, { - name: "No Split (Zero)", - sniSplit: 0, + name: "No Split", + sniSplit: 10, clientHello: []byte{}, // Empty client hello want: 0, }, diff --git a/x/examples/smart-proxy/snifrag.yaml b/x/examples/smart-proxy/snifrag.yaml index 5ca24e388..b988a6789 100644 --- a/x/examples/smart-proxy/snifrag.yaml +++ b/x/examples/smart-proxy/snifrag.yaml @@ -2,4 +2,4 @@ dns: - system: {} tls: - - snifrag:10 \ No newline at end of file + - snifrag:5 \ No newline at end of file From 831c751cd3bdd719b3bc9d7386d33d6571478666 Mon Sep 17 00:00:00 2001 From: Sarah Laplante Date: Tue, 12 Aug 2025 12:31:34 +0200 Subject: [PATCH 16/19] checkpoint --- transport/tlsfrag/split_sni.go | 30 +++++------------------------- 1 file changed, 5 insertions(+), 25 deletions(-) diff --git a/transport/tlsfrag/split_sni.go b/transport/tlsfrag/split_sni.go index fad7a2609..1b2af25ee 100644 --- a/transport/tlsfrag/split_sni.go +++ b/transport/tlsfrag/split_sni.go @@ -17,7 +17,7 @@ package tlsfrag import ( "bytes" - "src.agwa.name/tlshacks" + "github.com/Jigsaw-Code/getsni" ) // sniSplit can be positive or negative @@ -29,37 +29,17 @@ import ( func MakeSplitSniFunc(sniSplitOffset int) FragFunc { fragFunc := func(clientHello []byte) int { - hello := tlshacks.UnmarshalClientHello(clientHello) - // Failed parse - if hello == nil { + sni, err := getsni.GetSNI(clientHello) + if err != nil || sni == "" { return 0 } - var serverName string - // Find the Server Name Indication extension (type 0) - for _, ext := range hello.Extensions { - if ext.Type == 0 { // 0 is the type for the ServerNameData extension - if sni, ok := ext.Data.(*tlshacks.ServerNameData); ok { - if len(sni.HostName) > 0 { - // We only care about the first hostname. - serverName = sni.HostName - } - } - // We found the SNI extension, so we can stop searching. - break - } - } - - if serverName == "" { - // No SNI, don't split. - return 0 - } - sniLength := len(serverName) + sniLength := len(sni) // Adjust sniSplits that are negative or longer than sniLength to the correct value sniSplitOffset = sniSplitOffset % sniLength - sniIndex := bytes.Index(clientHello, []byte(serverName)) + sniIndex := bytes.Index(clientHello, []byte(sni)) if sniIndex == -1 { // This should not happen if parsing was successful and ServerName is not empty. // But as a safeguard, don't split. From e6021488c57bf7f311ec0f3bdee2d4a7a713e1e2 Mon Sep 17 00:00:00 2001 From: Sarah Laplante Date: Tue, 12 Aug 2025 15:47:17 +0200 Subject: [PATCH 17/19] add getsni lib --- go.mod | 1 + go.sum | 3 +++ x/go.mod | 1 + x/go.sum | 4 ++++ 4 files changed, 9 insertions(+) diff --git a/go.mod b/go.mod index c2ba0ade8..8b4931f63 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,7 @@ require ( ) require ( + github.com/Jigsaw-Code/getsni v1.0.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/emirpasic/gods v1.12.0 // indirect github.com/go-logr/logr v1.2.0 // indirect diff --git a/go.sum b/go.sum index 34fca52df..8f758d0a9 100644 --- a/go.sum +++ b/go.sum @@ -59,6 +59,8 @@ cloud.google.com/go/storage v1.22.1/go.mod h1:S8N1cAStu7BOeFfE8KAQzmyyLkK8p/vmRq dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/Jigsaw-Code/getsni v1.0.0 h1:OUTIu7wTBi/7DMX+RkZrN7XhU3UDevTEsAWK4gsqSwE= +github.com/Jigsaw-Code/getsni v1.0.0/go.mod h1:Ps0Ec3fVMKLyAItVbMKoQFq1lDjtFQXZ+G5nRNNh/QE= github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= github.com/alcortesm/tgz v0.0.0-20161220082320-9c5fe88206d7 h1:uSoVVbwJiQipAclBbw+8quDsfcvFjOpI5iCf4p/cqCs= github.com/alcortesm/tgz v0.0.0-20161220082320-9c5fe88206d7/go.mod h1:6zEj6s6u/ghQa61ZWa/C2Aw3RkjiTBOix7dkqa1VLIs= @@ -299,6 +301,7 @@ golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200604202706-70a84ac30bf9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= diff --git a/x/go.mod b/x/go.mod index d1a6ccf79..782140e81 100644 --- a/x/go.mod +++ b/x/go.mod @@ -29,6 +29,7 @@ require ( filippo.io/edwards25519 v1.1.0 // indirect filippo.io/keygen v0.0.0-20230306160926-5201437acf8e // indirect github.com/AndreasBriese/bbloom v0.0.0-20190825152654-46b345b51c96 // indirect + github.com/Jigsaw-Code/getsni v1.0.0 // indirect github.com/Psiphon-Labs/bolt v0.0.0-20200624191537-23cedaef7ad7 // indirect github.com/Psiphon-Labs/consistent v0.0.0-20240322131436-20aaa4e05737 // indirect github.com/Psiphon-Labs/goptlib v0.0.0-20200406165125-c0e32a7a3464 // indirect diff --git a/x/go.sum b/x/go.sum index 6be577a8d..0d65d5729 100644 --- a/x/go.sum +++ b/x/go.sum @@ -8,6 +8,8 @@ github.com/AndreasBriese/bbloom v0.0.0-20190825152654-46b345b51c96 h1:cTp8I5+VIo github.com/AndreasBriese/bbloom v0.0.0-20190825152654-46b345b51c96/go.mod h1:bOvUY6CB00SOBii9/FifXqc0awNKxLFCL/+pkDPuyl8= github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8= github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +github.com/Jigsaw-Code/getsni v1.0.0 h1:OUTIu7wTBi/7DMX+RkZrN7XhU3UDevTEsAWK4gsqSwE= +github.com/Jigsaw-Code/getsni v1.0.0/go.mod h1:Ps0Ec3fVMKLyAItVbMKoQFq1lDjtFQXZ+G5nRNNh/QE= github.com/Jigsaw-Code/outline-sdk v0.0.20 h1:4ep7MK9lFmcyPIRIbn4xrP1VKdJNsqR6+iJEOHDKnNg= github.com/Jigsaw-Code/outline-sdk v0.0.20/go.mod h1:CFDKyGZA4zatKE4vMLe8TyQpZCyINOeRFbMAmYHxodw= github.com/Jigsaw-Code/outline-ss-server v1.8.0 h1:6h7CZsyl1vQLz3nvxmL9FbhDug4QxJ1YTxm534eye1E= @@ -306,6 +308,7 @@ go4.org/mem v0.0.0-20220726221520-4f986261bf13/go.mod h1:reUoABIJ9ikfM5sgtSF3Wus go4.org/netipx v0.0.0-20230824141953-6213f710f925 h1:eeQDDVKFkx0g4Hyy8pHgmZaK0EqB4SD6rvKbUdN3ziQ= go4.org/netipx v0.0.0-20230824141953-6213f710f925/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200604202706-70a84ac30bf9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= @@ -350,6 +353,7 @@ golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200217220822-9197077df867/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= From a027ff5488046f756b3ebd63c4d33b022bfc76db Mon Sep 17 00:00:00 2001 From: Sarah Laplante Date: Wed, 13 Aug 2025 12:51:49 +0200 Subject: [PATCH 18/19] failing tests due to Bad TLSPlaintext --- transport/tlsfrag/getsni.go | 86 ++++++++++++++++++++++++++++++++ transport/tlsfrag/getsni_test.go | 69 +++++++++++++++++++++++++ transport/tlsfrag/split_sni.go | 4 ++ 3 files changed, 159 insertions(+) create mode 100644 transport/tlsfrag/getsni.go create mode 100644 transport/tlsfrag/getsni_test.go diff --git a/transport/tlsfrag/getsni.go b/transport/tlsfrag/getsni.go new file mode 100644 index 000000000..7ebebfb38 --- /dev/null +++ b/transport/tlsfrag/getsni.go @@ -0,0 +1,86 @@ +// Copyright 2009 The Go 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 tlsfrag + +import ( + "errors" + + "golang.org/x/crypto/cryptobyte" +) + +// GetSNI accepts the beginning of a TLS connection and returns the +// indicated server name, or an error if the server name was not found. +// Derived from unmarshal() in crypto/tls. +func GetSNI(clienthello []byte) (string, error) { + plaintext := cryptobyte.String(clienthello) + + var s cryptobyte.String + // Skip uint8 ContentType and uint16 ProtocolVersion + if !plaintext.Skip(1+2) || !plaintext.ReadUint16LengthPrefixed(&s) { + return "", errors.New("Bad TLSPlaintext") + } + + // Skip uint8 message type, uint24 length, uint16 version, and 32 byte random. + var sessionID cryptobyte.String + if !s.Skip(1+3+2+32) || + !s.ReadUint8LengthPrefixed(&sessionID) { + return "", errors.New("Bad Handshake message") + } + + var cipherSuites cryptobyte.String + if !s.ReadUint16LengthPrefixed(&cipherSuites) { + return "", errors.New("Bad ciphersuites") + } + + var compressionMethods cryptobyte.String + if !s.ReadUint8LengthPrefixed(&compressionMethods) { + return "", errors.New("Bad compression methods") + } + + if s.Empty() { + // ClientHello is optionally followed by extension data + return "", errors.New("Short hello") + } + + var extensions cryptobyte.String + if !s.ReadUint16LengthPrefixed(&extensions) || !s.Empty() { + return "", errors.New("Bad extensions") + } + + for !extensions.Empty() { + var extension uint16 + var extData cryptobyte.String + if !extensions.ReadUint16(&extension) || + !extensions.ReadUint16LengthPrefixed(&extData) { + return "", errors.New("Bad extension") + } + + switch extension { + case 0: // Extension ID 0 is ServerName + // RFC 6066, Section 3 + var nameList cryptobyte.String + if !extData.ReadUint16LengthPrefixed(&nameList) || nameList.Empty() { + return "", errors.New("Bad namelist") + } + for !nameList.Empty() { + var nameType uint8 + var serverName cryptobyte.String + if !nameList.ReadUint8(&nameType) || + !nameList.ReadUint16LengthPrefixed(&serverName) || + serverName.Empty() { + return "", errors.New("Bad SNI") + } + if nameType != 0 { + continue + } + return string(serverName), nil + } + default: + // Ignore all other extensions. + continue + } + } + return "", errors.New("No SNI") +} diff --git a/transport/tlsfrag/getsni_test.go b/transport/tlsfrag/getsni_test.go new file mode 100644 index 000000000..77f392fc1 --- /dev/null +++ b/transport/tlsfrag/getsni_test.go @@ -0,0 +1,69 @@ +package tlsfrag + +import ( + "encoding/hex" + "testing" +) + +// ClientHello for www.wikipedia.org, as a hex string. +const HEX = "1603010200010001fc0303168cafd33e2cde2db2c48f3e3ec1d32567c362e7c42f3f865768e2602e6bdeb020457210ccbdbe991fd206ff8481bfab5e7f2099038b48ec1a5220f03d2d574d7100222a2a130113021303c02bc02fc02cc030cca9cca8c013c014009c009d002f0035000a010001914a4a00000000001600140000117777772e77696b6970656469612e6f726700170000ff01000100000a000a0008caca001d00170018000b00020100002300000010000e000c02683208687474702f312e31000500050100000000000d00140012040308040401050308050501080606010201001200000033002b0029caca000100001d00202a9dfacdd81fa3a4c7300bdb6ee5d98e9774eb75c3fe7878d8a2b1802e092f6e002d00020101002b000b0a1a1a0304030303020301001b00030200020a0a000100001500c700000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + +func TestGetSNI(t *testing.T) { + clienthello, err := hex.DecodeString(HEX) + if err != nil { + t.Error(err) + return + } + sni, err := GetSNI(clienthello) + if err != nil { + t.Error(err) + return + } + if sni != "www.wikipedia.org" { + t.Errorf("Wrong SNI: %s", sni) + } +} + +func BenchmarkGetSNI(b *testing.B) { + clienthello, _ := hex.DecodeString(HEX) + for i := 0; i < b.N; i++ { + GetSNI(clienthello) + } +} + +func TestGetSNIShort(t *testing.T) { + clienthello, err := hex.DecodeString(HEX) + if err != nil { + t.Error(err) + return + } + // Only provide the first 64 bytes. This segment doesn't include the SNI, + // so GetSNI should return an error. + sni, err := GetSNI(clienthello[:64]) + if err == nil { + t.Error("Expected failure") + } + if sni != "" { + t.Errorf("Expected empty SNI: %s", sni) + } +} + +func TestGetSNILong(t *testing.T) { + clienthello, err := hex.DecodeString(HEX) + if err != nil { + t.Error(err) + return + } + // Append 100 bytes containing arbitrary data. + // GetSNI should ignore the extra data. + extra := [100]byte{17} + clienthello = append(clienthello, extra[:]...) + sni, err := GetSNI(clienthello) + if err != nil { + t.Error(err) + return + } + if sni != "www.wikipedia.org" { + t.Errorf("Wrong SNI: %s", sni) + } +} diff --git a/transport/tlsfrag/split_sni.go b/transport/tlsfrag/split_sni.go index 1b2af25ee..4d3c46d5b 100644 --- a/transport/tlsfrag/split_sni.go +++ b/transport/tlsfrag/split_sni.go @@ -16,6 +16,7 @@ package tlsfrag import ( "bytes" + "log" "github.com/Jigsaw-Code/getsni" ) @@ -30,6 +31,9 @@ func MakeSplitSniFunc(sniSplitOffset int) FragFunc { fragFunc := func(clientHello []byte) int { sni, err := getsni.GetSNI(clientHello) + + log.Printf("SNI: %v %v\n", sni, err) + if err != nil || sni == "" { return 0 } From 942074e6b422ed782b389a2ac9f7799f34d01cde Mon Sep 17 00:00:00 2001 From: Sarah Laplante Date: Mon, 18 Aug 2025 12:07:36 +0200 Subject: [PATCH 19/19] checkpoint --- transport/tlsfrag/getsni.go | 3 +++ transport/tlsfrag/split_sni.go | 14 ++++++++++---- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/transport/tlsfrag/getsni.go b/transport/tlsfrag/getsni.go index 7ebebfb38..c7445c299 100644 --- a/transport/tlsfrag/getsni.go +++ b/transport/tlsfrag/getsni.go @@ -6,6 +6,7 @@ package tlsfrag import ( "errors" + "log" "golang.org/x/crypto/cryptobyte" ) @@ -16,6 +17,8 @@ import ( func GetSNI(clienthello []byte) (string, error) { plaintext := cryptobyte.String(clienthello) + log.Printf("plaintext: %v \n", plaintext) + var s cryptobyte.String // Skip uint8 ContentType and uint16 ProtocolVersion if !plaintext.Skip(1+2) || !plaintext.ReadUint16LengthPrefixed(&s) { diff --git a/transport/tlsfrag/split_sni.go b/transport/tlsfrag/split_sni.go index 4d3c46d5b..e0c2828ad 100644 --- a/transport/tlsfrag/split_sni.go +++ b/transport/tlsfrag/split_sni.go @@ -16,9 +16,8 @@ package tlsfrag import ( "bytes" + "encoding/hex" "log" - - "github.com/Jigsaw-Code/getsni" ) // sniSplit can be positive or negative @@ -30,8 +29,15 @@ import ( func MakeSplitSniFunc(sniSplitOffset int) FragFunc { fragFunc := func(clientHello []byte) int { - sni, err := getsni.GetSNI(clientHello) + // The GetSNI function expects a full TLS record, but clientHello is just the payload. + // We prepend a fake record header to parse the SNI. + // The header is: record type (1) + version (2) + length (2). + header, _ := hex.DecodeString("1603010200") // Handshake, TLS 1.0, length 0 + fullClientHello := append(header, clientHello...) + + sni, err := GetSNI(fullClientHello) + log.Printf("clientHello: %x\n", fullClientHello) log.Printf("SNI: %v %v\n", sni, err) if err != nil || sni == "" { @@ -43,7 +49,7 @@ func MakeSplitSniFunc(sniSplitOffset int) FragFunc { // Adjust sniSplits that are negative or longer than sniLength to the correct value sniSplitOffset = sniSplitOffset % sniLength - sniIndex := bytes.Index(clientHello, []byte(sni)) + sniIndex := bytes.Index(fullClientHello, []byte(sni)) if sniIndex == -1 { // This should not happen if parsing was successful and ServerName is not empty. // But as a safeguard, don't split.