Browse Source

Add curve preferences, pinned public key SHA256 and mTLS for TLS options

世界 1 month ago
parent
commit
6c262c1e83

+ 4 - 1
common/tls/reality_server.go

@@ -68,7 +68,10 @@ func NewRealityServer(ctx context.Context, logger log.ContextLogger, options opt
 			return nil, E.New("unknown cipher_suite: ", cipherSuite)
 			return nil, E.New("unknown cipher_suite: ", cipherSuite)
 		}
 		}
 	}
 	}
-	if len(options.Certificate) > 0 || options.CertificatePath != "" {
+	if len(options.CurvePreferences) > 0 {
+		return nil, E.New("curve preferences is unavailable in reality")
+	}
+	if len(options.Certificate) > 0 || options.CertificatePath != "" || len(options.ClientCertificatePublicKeySHA256) > 0 {
 		return nil, E.New("certificate is unavailable in reality")
 		return nil, E.New("certificate is unavailable in reality")
 	}
 	}
 	if len(options.Key) > 0 || options.KeyPath != "" {
 	if len(options.Key) > 0 || options.KeyPath != "" {

+ 34 - 0
common/tls/std_client.go

@@ -1,9 +1,12 @@
 package tls
 package tls
 
 
 import (
 import (
+	"bytes"
 	"context"
 	"context"
+	"crypto/sha256"
 	"crypto/tls"
 	"crypto/tls"
 	"crypto/x509"
 	"crypto/x509"
+	"encoding/base64"
 	"net"
 	"net"
 	"os"
 	"os"
 	"strings"
 	"strings"
@@ -108,6 +111,15 @@ func NewSTDClient(ctx context.Context, logger logger.ContextLogger, serverAddres
 			return err
 			return err
 		}
 		}
 	}
 	}
+	if len(options.CertificatePublicKeySHA256) > 0 {
+		if len(options.Certificate) > 0 || options.CertificatePath != "" {
+			return nil, E.New("certificate_public_key_sha256 is conflict with certificate or certificate_path")
+		}
+		tlsConfig.InsecureSkipVerify = true
+		tlsConfig.VerifyPeerCertificate = func(rawCerts [][]byte, verifiedChains [][]*x509.Certificate) error {
+			return verifyPublicKeySHA256(options.CertificatePublicKeySHA256, rawCerts, tlsConfig.Time)
+		}
+	}
 	if len(options.ALPN) > 0 {
 	if len(options.ALPN) > 0 {
 		tlsConfig.NextProtos = options.ALPN
 		tlsConfig.NextProtos = options.ALPN
 	}
 	}
@@ -137,6 +149,9 @@ func NewSTDClient(ctx context.Context, logger logger.ContextLogger, serverAddres
 			return nil, E.New("unknown cipher_suite: ", cipherSuite)
 			return nil, E.New("unknown cipher_suite: ", cipherSuite)
 		}
 		}
 	}
 	}
+	for _, curve := range options.CurvePreferences {
+		tlsConfig.CurvePreferences = append(tlsConfig.CurvePreferences, tls.CurveID(curve))
+	}
 	var certificate []byte
 	var certificate []byte
 	if len(options.Certificate) > 0 {
 	if len(options.Certificate) > 0 {
 		certificate = []byte(strings.Join(options.Certificate, "\n"))
 		certificate = []byte(strings.Join(options.Certificate, "\n"))
@@ -175,3 +190,22 @@ func NewSTDClient(ctx context.Context, logger logger.ContextLogger, serverAddres
 	}
 	}
 	return config, nil
 	return config, nil
 }
 }
+
+func verifyPublicKeySHA256(knownHashValues [][]byte, rawCerts [][]byte, timeFunc func() time.Time) error {
+	leafCertificate, err := x509.ParseCertificate(rawCerts[0])
+	if err != nil {
+		return E.Cause(err, "failed to parse leaf certificate")
+	}
+
+	pubKeyBytes, err := x509.MarshalPKIXPublicKey(leafCertificate.PublicKey)
+	if err != nil {
+		return E.Cause(err, "failed to marshal public key")
+	}
+	hashValue := sha256.Sum256(pubKeyBytes)
+	for _, value := range knownHashValues {
+		if bytes.Equal(value, hashValue[:]) {
+			return nil
+		}
+	}
+	return E.New("unrecognized remote public key: ", base64.StdEncoding.EncodeToString(hashValue[:]))
+}

+ 93 - 20
common/tls/std_server.go

@@ -3,6 +3,7 @@ package tls
 import (
 import (
 	"context"
 	"context"
 	"crypto/tls"
 	"crypto/tls"
+	"crypto/x509"
 	"net"
 	"net"
 	"os"
 	"os"
 	"strings"
 	"strings"
@@ -22,16 +23,17 @@ import (
 var errInsecureUnused = E.New("tls: insecure unused")
 var errInsecureUnused = E.New("tls: insecure unused")
 
 
 type STDServerConfig struct {
 type STDServerConfig struct {
-	access          sync.RWMutex
-	config          *tls.Config
-	logger          log.Logger
-	acmeService     adapter.SimpleLifecycle
-	certificate     []byte
-	key             []byte
-	certificatePath string
-	keyPath         string
-	echKeyPath      string
-	watcher         *fswatch.Watcher
+	access                sync.RWMutex
+	config                *tls.Config
+	logger                log.Logger
+	acmeService           adapter.SimpleLifecycle
+	certificate           []byte
+	key                   []byte
+	certificatePath       string
+	keyPath               string
+	clientCertificatePath []string
+	echKeyPath            string
+	watcher               *fswatch.Watcher
 }
 }
 
 
 func (c *STDServerConfig) ServerName() string {
 func (c *STDServerConfig) ServerName() string {
@@ -111,6 +113,9 @@ func (c *STDServerConfig) startWatcher() error {
 	if c.echKeyPath != "" {
 	if c.echKeyPath != "" {
 		watchPath = append(watchPath, c.echKeyPath)
 		watchPath = append(watchPath, c.echKeyPath)
 	}
 	}
+	if len(c.clientCertificatePath) > 0 {
+		watchPath = append(watchPath, c.clientCertificatePath...)
+	}
 	if len(watchPath) == 0 {
 	if len(watchPath) == 0 {
 		return nil
 		return nil
 	}
 	}
@@ -159,6 +164,30 @@ func (c *STDServerConfig) certificateUpdated(path string) error {
 		c.config = config
 		c.config = config
 		c.access.Unlock()
 		c.access.Unlock()
 		c.logger.Info("reloaded TLS certificate")
 		c.logger.Info("reloaded TLS certificate")
+	} else if common.Contains(c.clientCertificatePath, path) {
+		clientCertificateCA := x509.NewCertPool()
+		var reloaded bool
+		for _, certPath := range c.clientCertificatePath {
+			content, err := os.ReadFile(certPath)
+			if err != nil {
+				c.logger.Error(E.Cause(err, "reload certificate from ", c.clientCertificatePath))
+				continue
+			}
+			if !clientCertificateCA.AppendCertsFromPEM(content) {
+				c.logger.Error(E.New("invalid client certificate file: ", certPath))
+				continue
+			}
+			reloaded = true
+		}
+		if !reloaded {
+			return E.New("client certificates is empty")
+		}
+		c.access.Lock()
+		config := c.config.Clone()
+		config.ClientCAs = clientCertificateCA
+		c.config = config
+		c.access.Unlock()
+		c.logger.Info("reloaded client certificates")
 	} else if path == c.echKeyPath {
 	} else if path == c.echKeyPath {
 		echKey, err := os.ReadFile(c.echKeyPath)
 		echKey, err := os.ReadFile(c.echKeyPath)
 		if err != nil {
 		if err != nil {
@@ -235,8 +264,14 @@ func NewSTDServer(ctx context.Context, logger log.ContextLogger, options option.
 			return nil, E.New("unknown cipher_suite: ", cipherSuite)
 			return nil, E.New("unknown cipher_suite: ", cipherSuite)
 		}
 		}
 	}
 	}
-	var certificate []byte
-	var key []byte
+	for _, curveID := range options.CurvePreferences {
+		tlsConfig.CurvePreferences = append(tlsConfig.CurvePreferences, tls.CurveID(curveID))
+	}
+	tlsConfig.ClientAuth = tls.ClientAuthType(options.ClientAuthentication)
+	var (
+		certificate []byte
+		key         []byte
+	)
 	if acmeService == nil {
 	if acmeService == nil {
 		if len(options.Certificate) > 0 {
 		if len(options.Certificate) > 0 {
 			certificate = []byte(strings.Join(options.Certificate, "\n"))
 			certificate = []byte(strings.Join(options.Certificate, "\n"))
@@ -278,6 +313,43 @@ func NewSTDServer(ctx context.Context, logger log.ContextLogger, options option.
 			tlsConfig.Certificates = []tls.Certificate{keyPair}
 			tlsConfig.Certificates = []tls.Certificate{keyPair}
 		}
 		}
 	}
 	}
+	if len(options.ClientCertificate) > 0 || len(options.ClientCertificatePath) > 0 {
+		if tlsConfig.ClientAuth == tls.NoClientCert {
+			tlsConfig.ClientAuth = tls.RequireAndVerifyClientCert
+		}
+	}
+	if tlsConfig.ClientAuth == tls.VerifyClientCertIfGiven || tlsConfig.ClientAuth == tls.RequireAndVerifyClientCert {
+		if len(options.ClientCertificate) > 0 {
+			clientCertificateCA := x509.NewCertPool()
+			if !clientCertificateCA.AppendCertsFromPEM([]byte(strings.Join(options.ClientCertificate, "\n"))) {
+				return nil, E.New("invalid client certificate strings")
+			}
+			tlsConfig.ClientCAs = clientCertificateCA
+		} else if len(options.ClientCertificatePath) > 0 {
+			clientCertificateCA := x509.NewCertPool()
+			for _, path := range options.ClientCertificatePath {
+				content, err := os.ReadFile(path)
+				if err != nil {
+					return nil, E.Cause(err, "read client certificate from ", path)
+				}
+				if !clientCertificateCA.AppendCertsFromPEM(content) {
+					return nil, E.New("invalid client certificate file: ", path)
+				}
+			}
+			tlsConfig.ClientCAs = clientCertificateCA
+		} else if len(options.ClientCertificatePublicKeySHA256) > 0 {
+			if tlsConfig.ClientAuth == tls.RequireAndVerifyClientCert {
+				tlsConfig.ClientAuth = tls.RequireAnyClientCert
+			} else if tlsConfig.ClientAuth == tls.VerifyClientCertIfGiven {
+				tlsConfig.ClientAuth = tls.RequestClientCert
+			}
+			tlsConfig.VerifyPeerCertificate = func(rawCerts [][]byte, verifiedChains [][]*x509.Certificate) error {
+				return verifyPublicKeySHA256(options.ClientCertificatePublicKeySHA256, rawCerts, tlsConfig.Time)
+			}
+		} else {
+			return nil, E.New("missing client_certificate, client_certificate_path or client_certificate_public_key_sha256 for client authentication")
+		}
+	}
 	var echKeyPath string
 	var echKeyPath string
 	if options.ECH != nil && options.ECH.Enabled {
 	if options.ECH != nil && options.ECH.Enabled {
 		err = parseECHServerConfig(ctx, options, tlsConfig, &echKeyPath)
 		err = parseECHServerConfig(ctx, options, tlsConfig, &echKeyPath)
@@ -286,14 +358,15 @@ func NewSTDServer(ctx context.Context, logger log.ContextLogger, options option.
 		}
 		}
 	}
 	}
 	serverConfig := &STDServerConfig{
 	serverConfig := &STDServerConfig{
-		config:          tlsConfig,
-		logger:          logger,
-		acmeService:     acmeService,
-		certificate:     certificate,
-		key:             key,
-		certificatePath: options.CertificatePath,
-		keyPath:         options.KeyPath,
-		echKeyPath:      echKeyPath,
+		config:                tlsConfig,
+		logger:                logger,
+		acmeService:           acmeService,
+		certificate:           certificate,
+		key:                   key,
+		certificatePath:       options.CertificatePath,
+		clientCertificatePath: options.ClientCertificatePath,
+		keyPath:               options.KeyPath,
+		echKeyPath:            echKeyPath,
 	}
 	}
 	serverConfig.config.GetConfigForClient = func(info *tls.ClientHelloInfo) (*tls.Config, error) {
 	serverConfig.config.GetConfigForClient = func(info *tls.ClientHelloInfo) (*tls.Config, error) {
 		serverConfig.access.Lock()
 		serverConfig.access.Lock()

+ 9 - 0
common/tls/utls_client.go

@@ -167,6 +167,15 @@ func NewUTLSClient(ctx context.Context, logger logger.ContextLogger, serverAddre
 		}
 		}
 		tlsConfig.InsecureServerNameToVerify = serverName
 		tlsConfig.InsecureServerNameToVerify = serverName
 	}
 	}
+	if len(options.CertificatePublicKeySHA256) > 0 {
+		if len(options.Certificate) > 0 || options.CertificatePath != "" {
+			return nil, E.New("certificate_public_key_sha256 is conflict with certificate or certificate_path")
+		}
+		tlsConfig.InsecureSkipVerify = true
+		tlsConfig.VerifyPeerCertificate = func(rawCerts [][]byte, verifiedChains [][]*x509.Certificate) error {
+			return verifyPublicKeySHA256(options.CertificatePublicKeySHA256, rawCerts, tlsConfig.Time)
+		}
+	}
 	if len(options.ALPN) > 0 {
 	if len(options.ALPN) > 0 {
 		tlsConfig.NextProtos = options.ALPN
 		tlsConfig.NextProtos = options.ALPN
 	}
 	}

+ 107 - 4
docs/configuration/shared/tls.md

@@ -5,7 +5,13 @@ icon: material/new-box
 !!! quote "Changes in sing-box 1.13.0"
 !!! quote "Changes in sing-box 1.13.0"
 
 
     :material-plus: [kernel_tx](#kernel_tx)  
     :material-plus: [kernel_tx](#kernel_tx)  
-    :material-plus: [kernel_rx](#kernel_rx)
+    :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_certificate_public_key_sha256](#client_certificate_public_key_sha256)
 
 
 !!! quote "Changes in sing-box 1.12.0"
 !!! quote "Changes in sing-box 1.12.0"
 
 
@@ -29,8 +35,13 @@ icon: material/new-box
   "min_version": "",
   "min_version": "",
   "max_version": "",
   "max_version": "",
   "cipher_suites": [],
   "cipher_suites": [],
+  "curve_preferences": [],
   "certificate": [],
   "certificate": [],
   "certificate_path": "",
   "certificate_path": "",
+  "client_authentication": "",
+  "client_certificate": [],
+  "client_certificate_path": [],
+  "client_certificate_public_key_sha256": [],
   "key": [],
   "key": [],
   "key_path": "",
   "key_path": "",
   "kernel_tx": false,
   "kernel_tx": false,
@@ -92,6 +103,7 @@ icon: material/new-box
   "cipher_suites": [],
   "cipher_suites": [],
   "certificate": "",
   "certificate": "",
   "certificate_path": "",
   "certificate_path": "",
+  "certificate_public_key_sha256": [],
   "fragment": false,
   "fragment": false,
   "fragment_fallback_delay": "",
   "fragment_fallback_delay": "",
   "record_fragment": false,
   "record_fragment": false,
@@ -195,14 +207,29 @@ By default, the maximum version is currently TLS 1.3.
 
 
 #### cipher_suites
 #### cipher_suites
 
 
-A list of enabled TLS 1.0–1.2 cipher suites. The order of the list is ignored.
+List of enabled TLS 1.0–1.2 cipher suites. The order of the list is ignored.
 Note that TLS 1.3 cipher suites are not configurable.
 Note that TLS 1.3 cipher suites are not configurable.
 
 
 If empty, a safe default list is used. The default cipher suites might change over time.
 If empty, a safe default list is used. The default cipher suites might change over time.
 
 
+#### curve_preferences
+
+!!! question "Since sing-box 1.13.0"
+
+Set of supported key exchange mechanisms. The order of the list is ignored, and key exchange mechanisms are chosen
+from this list using an internal preference order by Golang.
+
+Available values, also the default list:
+
+* `P256`
+* `P384`
+* `P521`
+* `X25519`
+* `X25519MLKEM768`
+
 #### certificate
 #### certificate
 
 
-The server certificate line array, in PEM format.
+Server certificates chain line array, in PEM format.
 
 
 #### certificate_path
 #### certificate_path
 
 
@@ -210,7 +237,26 @@ The server certificate line array, in PEM format.
 
 
     Will be automatically reloaded if file modified.
     Will be automatically reloaded if file modified.
 
 
-The path to the server certificate, in PEM format.
+The path to server certificate chain, in PEM format.
+
+
+#### certificate_public_key_sha256
+
+!!! question "Since sing-box 1.13.0"
+
+==Client only==
+
+List of SHA-256 hashes of server certificate public keys, in base64 format.
+
+To generate the SHA-256 hash for a certificate's public key, use the following commands:
+
+```bash
+# For a certificate file
+openssl x509 -in certificate.pem -pubkey -noout | openssl pkey -pubin -outform der | openssl dgst -sha256 -binary | openssl enc -base64
+
+# For a certificate from a remote server
+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
+```
 
 
 #### key
 #### key
 
 
@@ -228,6 +274,63 @@ The server private key line array, in PEM format.
 
 
 The path to the server private key, in PEM format.
 The path to the server private key, in PEM format.
 
 
+#### client_authentication
+
+!!! question "Since sing-box 1.13.0"
+
+==Server only==
+
+The type of client authentication to use.
+
+Available values:
+
+* `no` (default)
+* `request`
+* `require-any`
+* `verify-if-given`
+* `require-and-verify`
+
+One of `client_certificate`, `client_certificate_path`, or `client_certificate_public_key_sha256` is required
+if this option is set to `verify-if-given`, or `require-and-verify`.
+
+#### client_certificate
+
+!!! question "Since sing-box 1.13.0"
+
+==Server only==
+
+Client certificate chain line array, in PEM format.
+
+#### client_certificate_path
+
+!!! question "Since sing-box 1.13.0"
+
+==Server only==
+
+!!! note ""
+
+    Will be automatically reloaded if file modified.
+
+List of path to client certificate chain, in PEM format.
+
+#### client_certificate_public_key_sha256
+
+!!! question "Since sing-box 1.13.0"
+
+==Server only==
+
+List of SHA-256 hashes of client certificate public keys, in base64 format.
+
+To generate the SHA-256 hash for a certificate's public key, use the following commands:
+
+```bash
+# For a certificate file
+openssl x509 -in certificate.pem -pubkey -noout | openssl pkey -pubin -outform der | openssl dgst -sha256 -binary | openssl enc -base64
+
+# For a certificate from a remote server
+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
+```
+
 #### kernel_tx
 #### kernel_tx
 
 
 !!! question "Since sing-box 1.13.0"
 !!! question "Since sing-box 1.13.0"

+ 173 - 66
docs/configuration/shared/tls.zh.md

@@ -1,18 +1,24 @@
 ---
 ---
-icon: material/alert-decagram
+icon: material/new-box
 ---
 ---
 
 
 !!! quote "sing-box 1.13.0 中的更改"
 !!! quote "sing-box 1.13.0 中的更改"
 
 
-    :material-plus: [kernel_tx](#kernel_tx)  
+    :material-plus: [kernel_tx](#kernel_tx)
     :material-plus: [kernel_rx](#kernel_rx)
     :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_certificate_public_key_sha256](#client_certificate_public_key_sha256)
 
 
 !!! quote "sing-box 1.12.0 中的更改"
 !!! quote "sing-box 1.12.0 中的更改"
 
 
-    :material-plus: [tls_fragment](#tls_fragment)  
-    :material-plus: [tls_fragment_fallback_delay](#tls_fragment_fallback_delay)  
-    :material-plus: [tls_record_fragment](#tls_record_fragment)  
-    :material-delete-clock: [ech.pq_signature_schemes_enabled](#pq_signature_schemes_enabled)  
+    :material-plus: [fragment](#fragment)
+    :material-plus: [fragment_fallback_delay](#fragment_fallback_delay)
+    :material-plus: [record_fragment](#record_fragment)
+    :material-delete-clock: [ech.pq_signature_schemes_enabled](#pq_signature_schemes_enabled)
     :material-delete-clock: [ech.dynamic_record_sizing_disabled](#dynamic_record_sizing_disabled)
     :material-delete-clock: [ech.dynamic_record_sizing_disabled](#dynamic_record_sizing_disabled)
 
 
 !!! quote "sing-box 1.10.0 中的更改"
 !!! quote "sing-box 1.10.0 中的更改"
@@ -29,8 +35,13 @@ icon: material/alert-decagram
   "min_version": "",
   "min_version": "",
   "max_version": "",
   "max_version": "",
   "cipher_suites": [],
   "cipher_suites": [],
+  "curve_preferences": [],
   "certificate": [],
   "certificate": [],
   "certificate_path": "",
   "certificate_path": "",
+  "client_authentication": "",
+  "client_certificate": [],
+  "client_certificate_path": [],
+  "client_certificate_public_key_sha256": [],
   "key": [],
   "key": [],
   "key_path": "",
   "key_path": "",
   "kernel_tx": false,
   "kernel_tx": false,
@@ -90,17 +101,20 @@ icon: material/alert-decagram
   "min_version": "",
   "min_version": "",
   "max_version": "",
   "max_version": "",
   "cipher_suites": [],
   "cipher_suites": [],
-  "certificate": [],
+  "certificate": "",
   "certificate_path": "",
   "certificate_path": "",
+  "certificate_public_key_sha256": [],
   "fragment": false,
   "fragment": false,
   "fragment_fallback_delay": "",
   "fragment_fallback_delay": "",
   "record_fragment": false,
   "record_fragment": false,
   "ech": {
   "ech": {
     "enabled": false,
     "enabled": false,
-    "pq_signature_schemes_enabled": false,
-    "dynamic_record_sizing_disabled": false,
     "config": [],
     "config": [],
-    "config_path": ""
+    "config_path": "",
+
+    // 废弃的
+    "pq_signature_schemes_enabled": false,
+    "dynamic_record_sizing_disabled": false
   },
   },
   "utls": {
   "utls": {
     "enabled": false,
     "enabled": false,
@@ -191,13 +205,27 @@ TLS 版本值:
 
 
 #### cipher_suites
 #### cipher_suites
 
 
-启用的 TLS 1.0-1.2密码套件的列表。列表的顺序被忽略。请注意,TLS 1.3 的密码套件是不可配置的。
+启用的 TLS 1.0–1.2 密码套件列表。列表的顺序被忽略。请注意,TLS 1.3 的密码套件是不可配置的。
 
 
 如果为空,则使用安全的默认列表。默认密码套件可能会随着时间的推移而改变。
 如果为空,则使用安全的默认列表。默认密码套件可能会随着时间的推移而改变。
 
 
+#### curve_preferences
+
+!!! question "自 sing-box 1.13.0 起"
+
+支持的密钥交换机制集合。列表的顺序被忽略,密钥交换机制通过 Golang 的内部偏好顺序从此列表中选择。
+
+可用值,同时也是默认列表:
+
+* `P256`
+* `P384`
+* `P521`
+* `X25519`
+* `X25519MLKEM768`
+
 #### certificate
 #### certificate
 
 
-服务器 PEM 证书行数组。
+服务器证书行数组,PEM 格式
 
 
 #### certificate_path
 #### certificate_path
 
 
@@ -205,7 +233,25 @@ TLS 版本值:
 
 
     文件更改时将自动重新加载。
     文件更改时将自动重新加载。
 
 
-服务器 PEM 证书路径。
+服务器证书链路径,PEM 格式。
+
+#### certificate_public_key_sha256
+
+!!! question "自 sing-box 1.13.0 起"
+
+==仅客户端==
+
+服务器证书公钥的 SHA-256 哈希列表,base64 格式。
+
+要生成证书公钥的 SHA-256 哈希,请使用以下命令:
+
+```bash
+# 对于证书文件
+openssl x509 -in certificate.pem -pubkey -noout | openssl pkey -pubin -outform der | openssl dgst -sha256 -binary | openssl enc -base64
+
+# 对于远程服务器的证书
+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
+```
 
 
 #### key
 #### key
 
 
@@ -221,7 +267,68 @@ TLS 版本值:
 
 
 ==仅服务器==
 ==仅服务器==
 
 
-服务器 PEM 私钥路径。
+!!! note ""
+
+    文件更改时将自动重新加载。
+
+服务器私钥路径,PEM 格式。
+
+#### client_authentication
+
+!!! question "自 sing-box 1.13.0 起"
+
+==仅服务器==
+
+要使用的客户端身份验证类型。
+
+可用值:
+
+* `no`(默认)
+* `request`
+* `require-any`
+* `verify-if-given`
+* `require-and-verify`
+
+如果此选项设置为 `verify-if-given` 或 `require-and-verify`,
+则需要 `client_certificate`、`client_certificate_path` 或 `client_certificate_public_key_sha256` 中的一个。
+
+#### client_certificate
+
+!!! question "自 sing-box 1.13.0 起"
+
+==仅服务器==
+
+客户端证书链行数组,PEM 格式。
+
+#### client_certificate_path
+
+!!! question "自 sing-box 1.13.0 起"
+
+==仅服务器==
+
+!!! note ""
+
+    文件更改时将自动重新加载。
+
+客户端证书链路径列表,PEM 格式。
+
+#### client_certificate_public_key_sha256
+
+!!! question "自 sing-box 1.13.0 起"
+
+==仅服务器==
+
+客户端证书公钥的 SHA-256 哈希列表,base64 格式。
+
+要生成证书公钥的 SHA-256 哈希,请使用以下命令:
+
+```bash
+# 对于证书文件
+openssl x509 -in certificate.pem -pubkey -noout | openssl pkey -pubin -outform der | openssl dgst -sha256 -binary | openssl enc -base64
+
+# 对于远程服务器的证书
+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
+```
 
 
 #### kernel_tx
 #### kernel_tx
 
 
@@ -300,18 +407,36 @@ uTLS 是 "crypto/tls" 的一个分支,它提供了 ClientHello 指纹识别阻
 
 
 默认使用 chrome 指纹。
 默认使用 chrome 指纹。
 
 
-## ECH 字段
+### ECH 字段
+
+ECH (Encrypted Client Hello) 是一个 TLS 扩展,它允许客户端加密其 ClientHello 的第一部分信息。
+
+ECH 密钥和配置可以通过 `sing-box generate ech-keypair` 生成。
+
+#### pq_signature_schemes_enabled
+
+!!! failure "已在 sing-box 1.12.0 废弃"
 
 
-ECH (Encrypted Client Hello) 是一个 TLS 扩展,它允许客户端加密其 ClientHello 的第一部分
-信息。
+    ECH 支持已在 sing-box 1.12.0 迁移至使用标准库,但标准库不支持后量子对等证书签名方案,因此 `pq_signature_schemes_enabled` 已被弃用且不再工作。
 
 
-ECH 配置和密钥可以通过 `sing-box generate ech-keypair [--pq-signature-schemes-enabled]` 生成。
+启用对后量子对等证书签名方案的支持。
+
+#### dynamic_record_sizing_disabled
+
+!!! failure "已在 sing-box 1.12.0 废弃"
+
+    `dynamic_record_sizing_disabled` 与 ECH 无关,是错误添加的,现已弃用且不再工作。
+
+禁用 TLS 记录的自适应大小调整。
+
+当为 true 时,总是使用最大可能的 TLS 记录大小。
+当为 false 时,可能会调整 TLS 记录的大小以尝试改善延迟。
 
 
 #### key
 #### key
 
 
 ==仅服务器==
 ==仅服务器==
 
 
-ECH PEM 密钥行数组
+ECH 密钥行数组,PEM 格式。
 
 
 #### key_path
 #### key_path
 
 
@@ -321,13 +446,13 @@ ECH PEM 密钥行数组
 
 
     文件更改时将自动重新加载。
     文件更改时将自动重新加载。
 
 
-ECH PEM 密钥路径
+ECH 密钥路径,PEM 格式。
 
 
 #### config
 #### config
 
 
 ==仅客户端==
 ==仅客户端==
 
 
-ECH PEM 配置行数组
+ECH 配置行数组,PEM 格式。
 
 
 如果为空,将尝试从 DNS 加载。
 如果为空,将尝试从 DNS 加载。
 
 
@@ -335,79 +460,60 @@ ECH PEM 配置行数组
 
 
 ==仅客户端==
 ==仅客户端==
 
 
-ECH PEM 配置路径
+ECH 配置路径,PEM 格式。
 
 
 如果为空,将尝试从 DNS 加载。
 如果为空,将尝试从 DNS 加载。
 
 
-#### pq_signature_schemes_enabled
-
-!!! failure "已在 sing-box 1.12.0 废弃"
-
-    ECH 支持已在 sing-box 1.12.0 迁移至使用标准库,但标准库不支持后量子对等证书签名方案,因此 `pq_signature_schemes_enabled` 已被弃用且不再工作。
-
-启用对后量子对等证书签名方案的支持。
-
-建议匹配 `sing-box generate ech-keypair` 的参数。
-
-#### dynamic_record_sizing_disabled
-
-!!! failure "已在 sing-box 1.12.0 废弃"
-
-    `dynamic_record_sizing_disabled` 与 ECH 无关,是错误添加的,现已弃用且不再工作。
-
-禁用 TLS 记录的自适应大小调整。
-
-如果为 true,则始终使用最大可能的 TLS 记录大小。
-如果为 false,则可能会调整 TLS 记录的大小以尝试改善延迟。
-
-#### tls_fragment
+#### fragment
 
 
 !!! question "自 sing-box 1.12.0 起"
 !!! question "自 sing-box 1.12.0 起"
 
 
 ==仅客户端==
 ==仅客户端==
 
 
-通过分段 TLS 握手数据包来绕过防火墙检测
+通过分段 TLS 握手数据包来绕过防火墙。
 
 
-此功能旨在规避基于**明文数据包匹配**的简单防火墙,不应该用于规避真的审查。
+此功能旨在规避基于**明文数据包匹配**的简单防火墙,不应该用于规避真正的审查。
 
 
-由于性能不佳,请首先尝试 `tls_record_fragment`,且仅应用于已知被阻止的服务器名称。
+由于性能不佳,请首先尝试 `record_fragment`,且仅应用于已知被阻止的服务器名称。
 
 
-在 Linux、Apple 平台和需要管理员权限的 Windows 系统上,可自动检测等待时间。
-若无法自动检测,将回退使用 `tls_fragment_fallback_delay` 指定的固定等待时间。
+在 Linux、Apple 平台和(需要管理员权限的)Windows 系统上,
+可以自动检测等待时间。否则,将回退到
+等待 `fragment_fallback_delay` 指定的固定时间。
 
 
-此外,若实际等待时间小于 20 毫秒,同样会回退至固定等待时间模式,因为此时判定目标处于本地或透明代理之后。
+此外,如果实际等待时间少于 20ms,也会回退到等待固定时间,
+因为目标被认为是本地的或在透明代理后面。
 
 
-#### tls_fragment_fallback_delay
+#### fragment_fallback_delay
 
 
 !!! question "自 sing-box 1.12.0 起"
 !!! question "自 sing-box 1.12.0 起"
 
 
 ==仅客户端==
 ==仅客户端==
 
 
-当 TLS 分片功能无法自动判定等待时间时使用的回退值。
+当 TLS 分段无法自动确定等待时间时使用的回退值。
 
 
 默认使用 `500ms`。
 默认使用 `500ms`。
 
 
-#### tls_record_fragment
-
-==仅客户端==
+#### record_fragment
 
 
 !!! question "自 sing-box 1.12.0 起"
 !!! question "自 sing-box 1.12.0 起"
 
 
-通过分段 TLS 握手数据包到多个 TLS 记录来绕过防火墙检测。
+==仅客户端==
+
+将 TLS 握手分段为多个 TLS 记录以绕过防火墙。
 
 
 ### ACME 字段
 ### ACME 字段
 
 
 #### domain
 #### domain
 
 
-一组域名。
+域名列表
 
 
-默认禁用 ACME。
+如果为空则禁用 ACME。
 
 
 #### data_directory
 #### data_directory
 
 
-ACME 数据目录。
+ACME 数据存储目录。
 
 
-默认使用 `$XDG_DATA_HOME/certmagic|$HOME/.local/share/certmagic`。
+如果为空则使用 `$XDG_DATA_HOME/certmagic|$HOME/.local/share/certmagic`。
 
 
 #### default_server_name
 #### default_server_name
 
 
@@ -445,12 +551,11 @@ ACME 数据目录。
 
 
 #### external_account
 #### external_account
 
 
-EAB(外部帐户绑定)包含将 ACME 帐户绑定或映射到其他已知帐户所需的信息由 CA
+EAB(外部帐户绑定)包含将 ACME 帐户绑定或映射到 CA 已知的其他帐户所需的信息。
 
 
-外部帐户绑定用于将 ACME 帐户与非 ACME 系统中的现有帐户相关联,例如 CA 客户数据库。
+外部帐户绑定"用于将 ACME 帐户与非 ACME 系统中的现有帐户相关联,例如 CA 客户数据库。
 
 
-为了启用 ACME 帐户绑定,运行 ACME 服务器的 CA 需要向 ACME 客户端提供 MAC 密钥和密钥标识符,使用 ACME 之外的一些机制。
-§7.3.4
+为了启用 ACME 帐户绑定,运行 ACME 服务器的 CA 需要使用 ACME 之外的某种机制向 ACME 客户端提供 MAC 密钥和密钥标识符。§7.3.4
 
 
 #### external_account.key_id
 #### external_account.key_id
 
 
@@ -500,6 +605,8 @@ ACME DNS01 验证字段。如果配置,将禁用其他验证方法。
 
 
 #### max_time_difference
 #### max_time_difference
 
 
-服务器与和客户端之间允许的最大时间差。
+==仅服务器==
+
+服务器和客户端之间的最大时间差。
 
 
-默认禁用检查。
+如果为空则禁用检查。

+ 145 - 35
option/tls.go

@@ -1,24 +1,80 @@
 package option
 package option
 
 
-import "github.com/sagernet/sing/common/json/badoption"
+import (
+	"crypto/tls"
+	"encoding/json"
+	"strings"
+
+	E "github.com/sagernet/sing/common/exceptions"
+	"github.com/sagernet/sing/common/json/badoption"
+)
 
 
 type InboundTLSOptions struct {
 type InboundTLSOptions struct {
-	Enabled         bool                       `json:"enabled,omitempty"`
-	ServerName      string                     `json:"server_name,omitempty"`
-	Insecure        bool                       `json:"insecure,omitempty"`
-	ALPN            badoption.Listable[string] `json:"alpn,omitempty"`
-	MinVersion      string                     `json:"min_version,omitempty"`
-	MaxVersion      string                     `json:"max_version,omitempty"`
-	CipherSuites    badoption.Listable[string] `json:"cipher_suites,omitempty"`
-	Certificate     badoption.Listable[string] `json:"certificate,omitempty"`
-	CertificatePath string                     `json:"certificate_path,omitempty"`
-	Key             badoption.Listable[string] `json:"key,omitempty"`
-	KeyPath         string                     `json:"key_path,omitempty"`
-	KernelTx        bool                       `json:"kernel_tx,omitempty"`
-	KernelRx        bool                       `json:"kernel_rx,omitempty"`
-	ACME            *InboundACMEOptions        `json:"acme,omitempty"`
-	ECH             *InboundECHOptions         `json:"ech,omitempty"`
-	Reality         *InboundRealityOptions     `json:"reality,omitempty"`
+	Enabled                          bool                                `json:"enabled,omitempty"`
+	ServerName                       string                              `json:"server_name,omitempty"`
+	Insecure                         bool                                `json:"insecure,omitempty"`
+	ALPN                             badoption.Listable[string]          `json:"alpn,omitempty"`
+	MinVersion                       string                              `json:"min_version,omitempty"`
+	MaxVersion                       string                              `json:"max_version,omitempty"`
+	CipherSuites                     badoption.Listable[string]          `json:"cipher_suites,omitempty"`
+	CurvePreferences                 badoption.Listable[CurvePreference] `json:"curve_preferences,omitempty"`
+	Certificate                      badoption.Listable[string]          `json:"certificate,omitempty"`
+	CertificatePath                  string                              `json:"certificate_path,omitempty"`
+	ClientAuthentication             ClientAuthType                      `json:"client_authentication,omitempty"`
+	ClientCertificate                badoption.Listable[string]          `json:"client_certificate,omitempty"`
+	ClientCertificatePath            badoption.Listable[string]          `json:"client_certificate_path,omitempty"`
+	ClientCertificatePublicKeySHA256 badoption.Listable[[]byte]          `json:"client_certificate_public_key_sha256,omitempty"`
+	Key                              badoption.Listable[string]          `json:"key,omitempty"`
+	KeyPath                          string                              `json:"key_path,omitempty"`
+	KernelTx                         bool                                `json:"kernel_tx,omitempty"`
+	KernelRx                         bool                                `json:"kernel_rx,omitempty"`
+	ACME                             *InboundACMEOptions                 `json:"acme,omitempty"`
+	ECH                              *InboundECHOptions                  `json:"ech,omitempty"`
+	Reality                          *InboundRealityOptions              `json:"reality,omitempty"`
+}
+
+type ClientAuthType tls.ClientAuthType
+
+func (t ClientAuthType) MarshalJSON() ([]byte, error) {
+	var stringValue string
+	switch t {
+	case ClientAuthType(tls.NoClientCert):
+		stringValue = "no"
+	case ClientAuthType(tls.RequestClientCert):
+		stringValue = "request"
+	case ClientAuthType(tls.RequireAnyClientCert):
+		stringValue = "require-any"
+	case ClientAuthType(tls.VerifyClientCertIfGiven):
+		stringValue = "verify-if-given"
+	case ClientAuthType(tls.RequireAndVerifyClientCert):
+		stringValue = "require-and-verify"
+	default:
+		return nil, E.New("unknown client authentication type: ", int(t))
+	}
+	return json.Marshal(stringValue)
+}
+
+func (t *ClientAuthType) UnmarshalJSON(data []byte) error {
+	var stringValue string
+	err := json.Unmarshal(data, &stringValue)
+	if err != nil {
+		return err
+	}
+	switch stringValue {
+	case "no":
+		*t = ClientAuthType(tls.NoClientCert)
+	case "request":
+		*t = ClientAuthType(tls.RequestClientCert)
+	case "require-any":
+		*t = ClientAuthType(tls.RequireAnyClientCert)
+	case "verify-if-given":
+		*t = ClientAuthType(tls.VerifyClientCertIfGiven)
+	case "require-and-verify":
+		*t = ClientAuthType(tls.RequireAndVerifyClientCert)
+	default:
+		return E.New("unknown client authentication type: ", stringValue)
+	}
+	return nil
 }
 }
 
 
 type InboundTLSOptionsContainer struct {
 type InboundTLSOptionsContainer struct {
@@ -39,24 +95,26 @@ func (o *InboundTLSOptionsContainer) ReplaceInboundTLSOptions(options *InboundTL
 }
 }
 
 
 type OutboundTLSOptions struct {
 type OutboundTLSOptions struct {
-	Enabled               bool                       `json:"enabled,omitempty"`
-	DisableSNI            bool                       `json:"disable_sni,omitempty"`
-	ServerName            string                     `json:"server_name,omitempty"`
-	Insecure              bool                       `json:"insecure,omitempty"`
-	ALPN                  badoption.Listable[string] `json:"alpn,omitempty"`
-	MinVersion            string                     `json:"min_version,omitempty"`
-	MaxVersion            string                     `json:"max_version,omitempty"`
-	CipherSuites          badoption.Listable[string] `json:"cipher_suites,omitempty"`
-	Certificate           badoption.Listable[string] `json:"certificate,omitempty"`
-	CertificatePath       string                     `json:"certificate_path,omitempty"`
-	Fragment              bool                       `json:"fragment,omitempty"`
-	FragmentFallbackDelay badoption.Duration         `json:"fragment_fallback_delay,omitempty"`
-	RecordFragment        bool                       `json:"record_fragment,omitempty"`
-	KernelTx              bool                       `json:"kernel_tx,omitempty"`
-	KernelRx              bool                       `json:"kernel_rx,omitempty"`
-	ECH                   *OutboundECHOptions        `json:"ech,omitempty"`
-	UTLS                  *OutboundUTLSOptions       `json:"utls,omitempty"`
-	Reality               *OutboundRealityOptions    `json:"reality,omitempty"`
+	Enabled                    bool                                `json:"enabled,omitempty"`
+	DisableSNI                 bool                                `json:"disable_sni,omitempty"`
+	ServerName                 string                              `json:"server_name,omitempty"`
+	Insecure                   bool                                `json:"insecure,omitempty"`
+	ALPN                       badoption.Listable[string]          `json:"alpn,omitempty"`
+	MinVersion                 string                              `json:"min_version,omitempty"`
+	MaxVersion                 string                              `json:"max_version,omitempty"`
+	CipherSuites               badoption.Listable[string]          `json:"cipher_suites,omitempty"`
+	CurvePreferences           badoption.Listable[CurvePreference] `json:"curve_preferences,omitempty"`
+	Certificate                badoption.Listable[string]          `json:"certificate,omitempty"`
+	CertificatePath            string                              `json:"certificate_path,omitempty"`
+	CertificatePublicKeySHA256 badoption.Listable[[]byte]          `json:"certificate_public_key_sha256,omitempty"`
+	Fragment                   bool                                `json:"fragment,omitempty"`
+	FragmentFallbackDelay      badoption.Duration                  `json:"fragment_fallback_delay,omitempty"`
+	RecordFragment             bool                                `json:"record_fragment,omitempty"`
+	KernelTx                   bool                                `json:"kernel_tx,omitempty"`
+	KernelRx                   bool                                `json:"kernel_rx,omitempty"`
+	ECH                        *OutboundECHOptions                 `json:"ech,omitempty"`
+	UTLS                       *OutboundUTLSOptions                `json:"utls,omitempty"`
+	Reality                    *OutboundRealityOptions             `json:"reality,omitempty"`
 }
 }
 
 
 type OutboundTLSOptionsContainer struct {
 type OutboundTLSOptionsContainer struct {
@@ -76,6 +134,58 @@ func (o *OutboundTLSOptionsContainer) ReplaceOutboundTLSOptions(options *Outboun
 	o.TLS = options
 	o.TLS = options
 }
 }
 
 
+type CurvePreference tls.CurveID
+
+const (
+	CurveP256      = 23
+	CurveP384      = 24
+	CurveP521      = 25
+	X25519         = 29
+	X25519MLKEM768 = 4588
+)
+
+func (c CurvePreference) MarshalJSON() ([]byte, error) {
+	var stringValue string
+	switch c {
+	case CurvePreference(CurveP256):
+		stringValue = "P256"
+	case CurvePreference(CurveP384):
+		stringValue = "P384"
+	case CurvePreference(CurveP521):
+		stringValue = "P521"
+	case CurvePreference(X25519):
+		stringValue = "X25519"
+	case CurvePreference(X25519MLKEM768):
+		stringValue = "X25519MLKEM768"
+	default:
+		return nil, E.New("unknown curve id: ", int(c))
+	}
+	return json.Marshal(stringValue)
+}
+
+func (c *CurvePreference) UnmarshalJSON(data []byte) error {
+	var stringValue string
+	err := json.Unmarshal(data, &stringValue)
+	if err != nil {
+		return err
+	}
+	switch strings.ToUpper(stringValue) {
+	case "P256":
+		*c = CurvePreference(CurveP256)
+	case "P384":
+		*c = CurvePreference(CurveP384)
+	case "P521":
+		*c = CurvePreference(CurveP521)
+	case "X25519":
+		*c = CurvePreference(X25519)
+	case "X25519MLKEM768":
+		*c = CurvePreference(X25519MLKEM768)
+	default:
+		return E.New("unknown curve name: ", stringValue)
+	}
+	return nil
+}
+
 type InboundRealityOptions struct {
 type InboundRealityOptions struct {
 	Enabled           bool                           `json:"enabled,omitempty"`
 	Enabled           bool                           `json:"enabled,omitempty"`
 	Handshake         InboundRealityHandshakeOptions `json:"handshake,omitempty"`
 	Handshake         InboundRealityHandshakeOptions `json:"handshake,omitempty"`