From 1d91f1aa098f0bb6476f699efea3477a9cb97eae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Thu, 9 Oct 2025 23:10:34 +0800 Subject: [PATCH] Fix missing mTLS support in client options --- common/tls/std_client.go | 29 ++++++++++++++++ common/tls/utls_client.go | 29 ++++++++++++++++ docs/configuration/shared/tls.md | 53 +++++++++++++++++++++++++---- docs/configuration/shared/tls.zh.md | 41 +++++++++++++++++++++- option/tls.go | 4 +++ 5 files changed, 148 insertions(+), 8 deletions(-) diff --git a/common/tls/std_client.go b/common/tls/std_client.go index 265444ab7..1611c83e7 100644 --- a/common/tls/std_client.go +++ b/common/tls/std_client.go @@ -169,6 +169,35 @@ func NewSTDClient(ctx context.Context, logger logger.ContextLogger, serverAddres } tlsConfig.RootCAs = certPool } + var clientCertificate []byte + if len(options.ClientCertificate) > 0 { + clientCertificate = []byte(strings.Join(options.ClientCertificate, "\n")) + } else if options.ClientCertificatePath != "" { + content, err := os.ReadFile(options.ClientCertificatePath) + if err != nil { + return nil, E.Cause(err, "read client certificate") + } + clientCertificate = content + } + var clientKey []byte + if len(options.ClientKey) > 0 { + clientKey = []byte(strings.Join(options.ClientKey, "\n")) + } else if options.ClientKeyPath != "" { + content, err := os.ReadFile(options.ClientKeyPath) + if err != nil { + return nil, E.Cause(err, "read client key") + } + clientKey = content + } + if len(clientCertificate) > 0 && len(clientKey) > 0 { + keyPair, err := tls.X509KeyPair(clientCertificate, clientKey) + if err != nil { + return nil, E.Cause(err, "parse client x509 key pair") + } + tlsConfig.Certificates = []tls.Certificate{keyPair} + } else if len(clientCertificate) > 0 || len(clientKey) > 0 { + return nil, E.New("client certificate and client key must be provided together") + } var config Config = &STDClientConfig{ctx, &tlsConfig, options.Fragment, time.Duration(options.FragmentFallbackDelay), options.RecordFragment} if options.ECH != nil && options.ECH.Enabled { var err error diff --git a/common/tls/utls_client.go b/common/tls/utls_client.go index a277b992a..941192ba1 100644 --- a/common/tls/utls_client.go +++ b/common/tls/utls_client.go @@ -222,6 +222,35 @@ func NewUTLSClient(ctx context.Context, logger logger.ContextLogger, serverAddre } tlsConfig.RootCAs = certPool } + var clientCertificate []byte + if len(options.ClientCertificate) > 0 { + clientCertificate = []byte(strings.Join(options.ClientCertificate, "\n")) + } else if options.ClientCertificatePath != "" { + content, err := os.ReadFile(options.ClientCertificatePath) + if err != nil { + return nil, E.Cause(err, "read client certificate") + } + clientCertificate = content + } + var clientKey []byte + if len(options.ClientKey) > 0 { + clientKey = []byte(strings.Join(options.ClientKey, "\n")) + } else if options.ClientKeyPath != "" { + content, err := os.ReadFile(options.ClientKeyPath) + if err != nil { + return nil, E.Cause(err, "read client key") + } + clientKey = content + } + if len(clientCertificate) > 0 && len(clientKey) > 0 { + keyPair, err := utls.X509KeyPair(clientCertificate, clientKey) + if err != nil { + return nil, E.Cause(err, "parse client x509 key pair") + } + tlsConfig.Certificates = []utls.Certificate{keyPair} + } else if len(clientCertificate) > 0 || len(clientKey) > 0 { + return nil, E.New("client certificate and client key must be provided together") + } id, err := uTLSClientHelloID(options.UTLS.Fingerprint) if err != nil { return nil, err diff --git a/docs/configuration/shared/tls.md b/docs/configuration/shared/tls.md index 5533d8aeb..179a88987 100644 --- a/docs/configuration/shared/tls.md +++ b/docs/configuration/shared/tls.md @@ -4,13 +4,15 @@ icon: material/new-box !!! quote "Changes in sing-box 1.13.0" - :material-plus: [kernel_tx](#kernel_tx) - :material-plus: [kernel_rx](#kernel_rx) - :material-plus: [curve_preferences](#curve_preferences) - :material-plus: [certificate_public_key_sha256](#certificate_public_key_sha256) - :material-plus: [client_authentication](#client_authentication) - :material-plus: [client_certificate](#client_certificate) - :material-plus: [client_certificate_path](#client_certificate_path) + :material-plus: [kernel_tx](#kernel_tx) + :material-plus: [kernel_rx](#kernel_rx) + :material-plus: [curve_preferences](#curve_preferences) + :material-plus: [certificate_public_key_sha256](#certificate_public_key_sha256) + :material-plus: [client_certificate](#client_certificate) + :material-plus: [client_certificate_path](#client_certificate_path) + :material-plus: [client_key](#client_key) + :material-plus: [client_key_path](#client_key_path) + :material-plus: [client_authentication](#client_authentication) :material-plus: [client_certificate_public_key_sha256](#client_certificate_public_key_sha256) !!! quote "Changes in sing-box 1.12.0" @@ -101,9 +103,14 @@ icon: material/new-box "min_version": "", "max_version": "", "cipher_suites": [], + "curve_preferences": [], "certificate": "", "certificate_path": "", "certificate_public_key_sha256": [], + "client_certificate": [], + "client_certificate_path": "", + "client_key": [], + "client_key_path": "", "fragment": false, "fragment_fallback_delay": "", "record_fragment": false, @@ -258,6 +265,38 @@ openssl x509 -in certificate.pem -pubkey -noout | openssl pkey -pubin -outform d echo | openssl s_client -servername example.com -connect example.com:443 2>/dev/null | openssl x509 -pubkey -noout | openssl pkey -pubin -outform der | openssl dgst -sha256 -binary | openssl enc -base64 ``` +#### client_certificate + +!!! question "Since sing-box 1.13.0" + +==Client only== + +Client certificate chain line array, in PEM format. + +#### client_certificate_path + +!!! question "Since sing-box 1.13.0" + +==Client only== + +The path to client certificate chain, in PEM format. + +#### client_key + +!!! question "Since sing-box 1.13.0" + +==Client only== + +Client private key line array, in PEM format. + +#### client_key_path + +!!! question "Since sing-box 1.13.0" + +==Client only== + +The path to client private key, in PEM format. + #### key ==Server only== diff --git a/docs/configuration/shared/tls.zh.md b/docs/configuration/shared/tls.zh.md index e6468d452..5bd429cb4 100644 --- a/docs/configuration/shared/tls.zh.md +++ b/docs/configuration/shared/tls.zh.md @@ -8,9 +8,11 @@ icon: material/new-box :material-plus: [kernel_rx](#kernel_rx) :material-plus: [curve_preferences](#curve_preferences) :material-plus: [certificate_public_key_sha256](#certificate_public_key_sha256) - :material-plus: [client_authentication](#client_authentication) :material-plus: [client_certificate](#client_certificate) :material-plus: [client_certificate_path](#client_certificate_path) + :material-plus: [client_key](#client_key) + :material-plus: [client_key_path](#client_key_path) + :material-plus: [client_authentication](#client_authentication) :material-plus: [client_certificate_public_key_sha256](#client_certificate_public_key_sha256) !!! quote "sing-box 1.12.0 中的更改" @@ -101,9 +103,14 @@ icon: material/new-box "min_version": "", "max_version": "", "cipher_suites": [], + "curve_preferences": [], "certificate": "", "certificate_path": "", "certificate_public_key_sha256": [], + "client_certificate": [], + "client_certificate_path": "", + "client_key": [], + "client_key_path": "", "fragment": false, "fragment_fallback_delay": "", "record_fragment": false, @@ -253,6 +260,38 @@ openssl x509 -in certificate.pem -pubkey -noout | openssl pkey -pubin -outform d echo | openssl s_client -servername example.com -connect example.com:443 2>/dev/null | openssl x509 -pubkey -noout | openssl pkey -pubin -outform der | openssl dgst -sha256 -binary | openssl enc -base64 ``` +#### client_certificate + +!!! question "自 sing-box 1.13.0 起" + +==仅客户端== + +客户端证书链行数组,PEM 格式。 + +#### client_certificate_path + +!!! question "自 sing-box 1.13.0 起" + +==仅客户端== + +客户端证书链路径,PEM 格式。 + +#### client_key + +!!! question "自 sing-box 1.13.0 起" + +==仅客户端== + +客户端私钥行数组,PEM 格式。 + +#### client_key_path + +!!! question "自 sing-box 1.13.0 起" + +==仅客户端== + +客户端私钥路径,PEM 格式。 + #### key ==仅服务器== diff --git a/option/tls.go b/option/tls.go index 90f9a55a9..1829898a9 100644 --- a/option/tls.go +++ b/option/tls.go @@ -107,6 +107,10 @@ type OutboundTLSOptions struct { Certificate badoption.Listable[string] `json:"certificate,omitempty"` CertificatePath string `json:"certificate_path,omitempty"` CertificatePublicKeySHA256 badoption.Listable[[]byte] `json:"certificate_public_key_sha256,omitempty"` + ClientCertificate badoption.Listable[string] `json:"client_certificate,omitempty"` + ClientCertificatePath string `json:"client_certificate_path,omitempty"` + ClientKey badoption.Listable[string] `json:"client_key,omitempty"` + ClientKeyPath string `json:"client_key_path,omitempty"` Fragment bool `json:"fragment,omitempty"` FragmentFallbackDelay badoption.Duration `json:"fragment_fallback_delay,omitempty"` RecordFragment bool `json:"record_fragment,omitempty"`