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 ""