From f86bc224dc68d9c862cd6d36b6dcb6613f2df2ec Mon Sep 17 00:00:00 2001 From: wwqgtxx Date: Thu, 16 Jul 2026 22:17:24 +0800 Subject: [PATCH] fix: align HelloRetryRequest handling with JLS v3 JLS v3 does not permit HelloRetryRequest during JLS authentication. Keep the ClientHello random unchanged across HRR and make clients continue as ordinary TLS with certificate verification. When an authenticated ClientHello would require HRR, fail before writing handshake data and wrap ErrJLSAuthFailed. This allows stream and QUIC callers to forward the untouched handshake to the camouflage fallback instead of accepting a non-standard JLS exchange. --- go.mod | 4 +- go.sum | 8 ++-- transport/jls/jls.go | 12 +++-- transport/jls/jls_test.go | 83 ++++++++++++++++++++++++++++------ transport/jls/utls.go | 15 +++--- transport/shadowquic/dial.go | 2 +- transport/shadowquic/server.go | 2 +- 7 files changed, 90 insertions(+), 36 deletions(-) diff --git a/go.mod b/go.mod index 6af719a3..1c3facb2 100644 --- a/go.mod +++ b/go.mod @@ -23,8 +23,8 @@ require ( github.com/metacubex/fswatch v0.1.1 github.com/metacubex/gopacket v1.1.20-0.20230608035415-7e2f98a3e759 github.com/metacubex/http v0.1.6 - github.com/metacubex/jls-quic-go v0.0.0-20260716020038-12287ee553d3 - github.com/metacubex/jls-tls v0.0.0-20260716010121-e13c4e54a728 + github.com/metacubex/jls-quic-go v0.0.0-20260716141257-bee72c5b0a6c + github.com/metacubex/jls-tls v0.0.0-20260716140250-3d001a2e3788 github.com/metacubex/kcp-go v0.0.0-20260105040817-550693377604 github.com/metacubex/mhurl v0.1.0 github.com/metacubex/mlkem v0.1.0 diff --git a/go.sum b/go.sum index ff403b84..e1046e53 100644 --- a/go.sum +++ b/go.sum @@ -131,10 +131,10 @@ github.com/metacubex/hpke v0.1.0 h1:gu2jUNhraehWi0P/z5HX2md3d7L1FhPQE6/Q0E9r9xQ= github.com/metacubex/hpke v0.1.0/go.mod h1:vfDm6gfgrwlXUxKDkWbcE44hXtmc1uxLDm2BcR11b3U= github.com/metacubex/http v0.1.6 h1:xvXuvXMCMxCWMF5nEJF4yiKvXL+p2atWMzs37e80m1I= github.com/metacubex/http v0.1.6/go.mod h1:Nxx0zZAo2AhRfanyL+fmmK6ACMtVsfpwIl1aFAik2Eg= -github.com/metacubex/jls-quic-go v0.0.0-20260716020038-12287ee553d3 h1:JuRPjylaOoVvSR9g/fuePbquEzHEgbeAeKSa1aaJ7bI= -github.com/metacubex/jls-quic-go v0.0.0-20260716020038-12287ee553d3/go.mod h1:6Ge9WhYeId1yJLD4kgyMDgFmcPJzmDAkHNc9TpzCCXw= -github.com/metacubex/jls-tls v0.0.0-20260716010121-e13c4e54a728 h1:4IbvO5xjKMJLs5GmLl0fYI+lDcJxAoZ3hJtaqq2/YC0= -github.com/metacubex/jls-tls v0.0.0-20260716010121-e13c4e54a728/go.mod h1:mmqs889W/TqPlfNRDa2UyJvRiLyiTJIEnWHkcj3SKB8= +github.com/metacubex/jls-quic-go v0.0.0-20260716141257-bee72c5b0a6c h1:TK7l/879ivHrWnRQ+IZAuLCpJH4eTvo1ylNxtWF3nVk= +github.com/metacubex/jls-quic-go v0.0.0-20260716141257-bee72c5b0a6c/go.mod h1:q4vsumnl4LVzMuVawpwvnWRIHSyOzp3b0n1ZY9A9wvo= +github.com/metacubex/jls-tls v0.0.0-20260716140250-3d001a2e3788 h1:vqtCy6N0WiYwk4/4vL3qibvWbBjoQOxBgNRLeNRFXuQ= +github.com/metacubex/jls-tls v0.0.0-20260716140250-3d001a2e3788/go.mod h1:mmqs889W/TqPlfNRDa2UyJvRiLyiTJIEnWHkcj3SKB8= github.com/metacubex/jsonv2 v0.0.0-20260518173308-f4597c22f1df h1:S0vBzqjXok24VopstOgPd1JdgglW9tXehrqvwpQWbQ8= github.com/metacubex/jsonv2 v0.0.0-20260518173308-f4597c22f1df/go.mod h1:F4sVXat6QjPXkNsKRDyyG3BhSkxPFFnRPEIwmmyCgbg= github.com/metacubex/kcp-go v0.0.0-20260105040817-550693377604 h1:hJwCVlE3ojViC35MGHB+FBr8TuIf3BUFn2EQ1VIamsI= diff --git a/transport/jls/jls.go b/transport/jls/jls.go index fd3b9e67..ad66d86a 100644 --- a/transport/jls/jls.go +++ b/transport/jls/jls.go @@ -112,7 +112,7 @@ func NewClient(ctx context.Context, conn net.Conn, config *ClientConfig) (net.Co if err := tlsConn.HandshakeContext(ctx); err != nil { return nil, err } - if !tlsConn.ConnectionState().JLS.Authenticated { + if tlsConn.ConnectionState().JLS.Status != tls.JLSAuthenticated { return nil, ErrJLSAuthFailed } return tlsConn, nil @@ -181,14 +181,14 @@ func Server(ctx context.Context, conn net.Conn, config *ServerConfig) (net.Conn, tlsConn := tls.Server(recorder, config.TLSConfig.Clone()) if err := tlsConn.HandshakeContext(ctx); err != nil { // Forwarding after authentication or a local write would mix two server handshakes. - if tlsConn.ConnectionState().JLS.Authenticated || recorder.wroteToClient() { + if tlsConn.ConnectionState().JLS.Status == tls.JLSAuthenticated || recorder.wroteToClient() { recorder.discard() return nil, err } return nil, relayFallback(ctx, conn, recorder.stop(), config) } recorder.discard() - if !tlsConn.ConnectionState().JLS.Authenticated { + if tlsConn.ConnectionState().JLS.Status != tls.JLSAuthenticated { return nil, ErrJLSAuthFailed } return tlsConn, nil @@ -196,13 +196,15 @@ func Server(ctx context.Context, conn net.Conn, config *ServerConfig) (net.Conn, func UserFromConn(conn net.Conn) (string, bool) { tlsConn, ok := N.FindUpstream(conn, func(tlsConn *tls.Conn) bool { - state := tlsConn.ConnectionState().JLS - return state.Authenticated && state.User != "" + return tlsConn.ConnectionState().JLS.Status != tls.JLSDisabled }) if !ok { return "", false } state := tlsConn.ConnectionState().JLS + if state.Status != tls.JLSAuthenticated || state.User == "" { + return "", false + } return state.User, true } diff --git a/transport/jls/jls_test.go b/transport/jls/jls_test.go index 9fe7e97c..522dd1aa 100644 --- a/transport/jls/jls_test.go +++ b/transport/jls/jls_test.go @@ -15,6 +15,7 @@ import ( "github.com/metacubex/http" "github.com/metacubex/http/httptest" tls "github.com/metacubex/jls-tls" + httpTLS "github.com/metacubex/tls" ) func TestJLSClientServer(t *testing.T) { @@ -53,7 +54,7 @@ func testJLSClientServer(t *testing.T, clientFingerprint string) { } defer conn.Close() state := conn.(*tls.Conn).ConnectionState() - if !state.JLS.Authenticated || state.JLS.User != user.Username { + if state.JLS.Status != tls.JLSAuthenticated || state.JLS.User != user.Username { serverDone <- errors.New("server did not authenticate JLS user") return } @@ -62,6 +63,11 @@ func testJLSClientServer(t *testing.T, clientFingerprint string) { serverDone <- errors.New("server did not expose JLS user") return } + outerJLS := tls.Server(outerTLS, &tls.Config{JLSConfig: &tls.JLSConfig{Enable: true}}) + if _, ok := UserFromConn(outerJLS); ok { + serverDone <- errors.New("server exposed an inner JLS user through an unauthenticated outer JLS connection") + return + } _, err = io.Copy(conn, conn) serverDone <- err }() @@ -112,14 +118,32 @@ func TestJLSUTLSClientRejectsInvalidFallbackCertificate(t *testing.T) { } func TestJLSUTLSClientFallback(t *testing.T) { - for _, protocol := range []string{"http/1.1", "h2"} { - t.Run(protocol, func(t *testing.T) { + for _, test := range []struct { + name string + protocol string + hrr bool + }{ + {name: "HTTP/1.1", protocol: "http/1.1"}, + {name: "HTTP/2", protocol: "h2"}, + {name: "HelloRetryRequest", protocol: "http/1.1", hrr: true}, + } { + t.Run(test.name, func(t *testing.T) { requestProtocol := make(chan string, 1) + hrrObserved := make(chan bool, 1) server := httptest.NewUnstartedServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { requestProtocol <- request.Proto writer.WriteHeader(http.StatusNoContent) })) - server.EnableHTTP2 = protocol == "h2" + server.EnableHTTP2 = test.protocol == "h2" + if test.hrr { + server.TLS = &httpTLS.Config{ + CurvePreferences: []httpTLS.CurveID{httpTLS.CurveP256}, + VerifyConnection: func(state httpTLS.ConnectionState) error { + hrrObserved <- state.HelloRetryRequest + return nil + }, + } + } server.StartTLS() defer func() { server.CloseClientConnections() @@ -131,7 +155,7 @@ func TestJLSUTLSClientFallback(t *testing.T) { if err != nil { t.Fatal(err) } - config, err := NewClientConfig("example.com", "user", "password", []string{protocol}) + config, err := NewClientConfig("example.com", "user", "password", []string{test.protocol}) if err != nil { t.Fatal(err) } @@ -144,7 +168,7 @@ func TestJLSUTLSClientFallback(t *testing.T) { } wantProtocol := "HTTP/1.1" - if protocol == "h2" { + if test.protocol == "h2" { wantProtocol = "HTTP/2.0" } select { @@ -155,14 +179,31 @@ func TestJLSUTLSClientFallback(t *testing.T) { default: t.Fatal("fallback HTTP request was not sent") } + if test.hrr && !<-hrrObserved { + t.Fatal("ordinary TLS fallback did not exercise HelloRetryRequest") + } }) } } -func TestJLSUTLSClientRejectsHelloRetryRequest(t *testing.T) { +func TestJLSServerFallsBackInsteadOfHelloRetryRequest(t *testing.T) { user := User{Username: "user", Password: "password"} - serverConfig, err := NewServerConfig("camouflage.example", "camouflage.example:443", []User{user}, nil, 0, func(context.Context, string, string) (net.Conn, error) { - return nil, errors.New("HRR failure dialed fallback") + fallbackRequest := make(chan struct{}, 1) + fallback := httptest.NewUnstartedServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + fallbackRequest <- struct{}{} + writer.WriteHeader(http.StatusNoContent) + })) + fallback.StartTLS() + defer func() { + fallback.CloseClientConnections() + fallback.Close() + }() + ca.GetCertPool().AddCert(fallback.Certificate()) + + fallbackDialed := make(chan struct{}, 1) + serverConfig, err := NewServerConfig("example.com", fallback.Listener.Addr().String(), []User{user}, nil, 0, func(ctx context.Context, network, _ string) (net.Conn, error) { + fallbackDialed <- struct{}{} + return (&net.Dialer{}).DialContext(ctx, network, fallback.Listener.Addr().String()) }) if err != nil { t.Fatal(err) @@ -177,18 +218,30 @@ func TestJLSUTLSClientRejectsHelloRetryRequest(t *testing.T) { serverDone <- serverErr }() - clientConfig, err := NewClientConfig("camouflage.example", user.Username, user.Password, nil) + clientConfig, err := NewClientConfig("example.com", user.Username, user.Password, nil) if err != nil { t.Fatal(err) } clientConfig.ClientFingerprint = "chrome" - if conn, clientErr := NewClient(context.Background(), clientSide, clientConfig); clientErr == nil { - _ = conn.Close() - t.Fatal("uTLS client unexpectedly completed a HelloRetryRequest handshake") + if conn, clientErr := NewClient(context.Background(), clientSide, clientConfig); !errors.Is(clientErr, ErrJLSAuthFailed) { + if conn != nil { + _ = conn.Close() + } + t.Fatalf("client error = %v, want %v", clientErr, ErrJLSAuthFailed) } _ = clientSide.Close() - if err = <-serverDone; err == nil { - t.Fatal("JLS server unexpectedly completed a HelloRetryRequest handshake") + if err = <-serverDone; !errors.Is(err, ErrFallbackCompleted) { + t.Fatalf("server error = %v, want %v", err, ErrFallbackCompleted) + } + select { + case <-fallbackDialed: + default: + t.Fatal("JLS server did not dial fallback") + } + select { + case <-fallbackRequest: + default: + t.Fatal("fallback HTTP request was not sent") } } diff --git a/transport/jls/utls.go b/transport/jls/utls.go index 8006d1eb..a3fe269a 100644 --- a/transport/jls/utls.go +++ b/transport/jls/utls.go @@ -93,7 +93,7 @@ func newUTLSClient(ctx context.Context, conn net.Conn, config *ClientConfig) (ne if err = uConn.BuildHandshakeState(); err != nil { return nil, true, err } - sentClientHello := append([]byte(nil), uConn.HandshakeState.Hello.Raw...) + verifier.clientHello = append([]byte(nil), uConn.HandshakeState.Hello.Raw...) if err = uConn.HandshakeContext(ctx); err != nil { return nil, true, err @@ -105,13 +105,6 @@ func newUTLSClient(ctx context.Context, conn net.Conn, config *ClientConfig) (ne jlsClientHTTPFallback(ctx, uConn, config.ServerName, fingerprint) return nil, true, ErrJLSAuthFailed } - // A HelloRetryRequest makes uTLS generate another ClientHello. Its public API - // cannot recalculate the JLS random at that point, so reject it instead of - // accepting a connection whose second ClientHello was not authenticated. - if !bytes.Equal(sentClientHello, uConn.HandshakeState.Hello.Raw) { - _ = uConn.Close() - return nil, true, errors.New("jls: uTLS HelloRetryRequest is not supported") - } if uConn.ConnectionState().Version != utls.VersionTLS13 { _ = uConn.Close() return nil, true, ErrJLSAuthFailed @@ -164,10 +157,16 @@ type utlsJLSVerifier struct { *utls.UConn user User serverName string + clientHello []byte authenticated bool } func (v *utlsJLSVerifier) VerifyConnection(state utls.ConnectionState) error { + // JLS v3 does not permit HelloRetryRequest at any stage. uTLS replaces + // HandshakeState.Hello when it sends the second ClientHello. + if !bytes.Equal(v.clientHello, v.HandshakeState.Hello.Raw) { + return verifyUTLSCertificate(state, v.serverName) + } serverHello := v.HandshakeState.ServerHello if serverHello == nil { return errors.New("jls: uTLS server hello is unavailable") diff --git a/transport/shadowquic/dial.go b/transport/shadowquic/dial.go index 31c96725..a0348ff1 100644 --- a/transport/shadowquic/dial.go +++ b/transport/shadowquic/dial.go @@ -72,7 +72,7 @@ func monitorJLSAuth(quicConn *quic.Conn, packetConn net.PacketConn, tlsConf *tls _ = packetConn.Close() } checkAuth := func() bool { - return quicConn.ConnectionState().TLS.JLS.Authenticated + return quicConn.ConnectionState().TLS.JLS.Status == tls.JLSAuthenticated } if !early { if checkAuth() { diff --git a/transport/shadowquic/server.go b/transport/shadowquic/server.go index 978b82e4..d716dfaf 100644 --- a/transport/shadowquic/server.go +++ b/transport/shadowquic/server.go @@ -210,7 +210,7 @@ func (s *Server) jlsAdditions(state *connState) []inbound.Addition { func (s *Server) jlsUser(state *connState) string { tlsState := state.quicConn.ConnectionState().TLS - if tlsState.JLS.Authenticated { + if tlsState.JLS.Status == tls.JLSAuthenticated { return tlsState.JLS.User } return ""