diff --git a/app/cmd/client.go b/app/cmd/client.go index 05fec80..83bf5e1 100644 --- a/app/cmd/client.go +++ b/app/cmd/client.go @@ -2,6 +2,7 @@ package cmd import ( "crypto/sha256" + "crypto/tls" "crypto/x509" "encoding/hex" "errors" @@ -96,10 +97,12 @@ type clientConfigObfs struct { } type clientConfigTLS struct { - SNI string `mapstructure:"sni"` - Insecure bool `mapstructure:"insecure"` - PinSHA256 string `mapstructure:"pinSHA256"` - CA string `mapstructure:"ca"` + SNI string `mapstructure:"sni"` + Insecure bool `mapstructure:"insecure"` + PinSHA256 string `mapstructure:"pinSHA256"` + CA string `mapstructure:"ca"` + ClientCertificate string `mapstructure:"clientCertificate"` + ClientKey string `mapstructure:"clientKey"` } type clientConfigQUIC struct { @@ -294,6 +297,31 @@ func (c *clientConfig) fillTLSConfig(hyConfig *client.Config) error { } hyConfig.TLSConfig.RootCAs = cPool } + if c.TLS.ClientCertificate != "" && c.TLS.ClientKey != "" { + certLoader := &utils.LocalCertificateLoader{ + CertFile: c.TLS.ClientCertificate, + KeyFile: c.TLS.ClientKey, + } + // Try loading the cert-key pair here to catch errors early + err := certLoader.InitializeCache() + if err != nil { + var pathErr *os.PathError + if errors.As(err, &pathErr) { + if pathErr.Path == c.TLS.ClientCertificate { + return configError{Field: "tls.clientCertificate", Err: pathErr} + } + if pathErr.Path == c.TLS.ClientKey { + return configError{Field: "tls.clientKey", Err: pathErr} + } + } + return configError{Field: "tls.clientCertificate", Err: err} + } + // Use GetClientCertificates so that users can update the cert without restarting the client. + hyConfig.TLSConfig.GetClientCertificate = func(*tls.CertificateRequestInfo) (*tls.Certificate, error) { + // For simplicity, always respond with the configured client certs, regardless of server requests. + return certLoader.GetCertificate(nil) + } + } return nil } diff --git a/app/cmd/client_test.go b/app/cmd/client_test.go index 10b2d99..56d1067 100644 --- a/app/cmd/client_test.go +++ b/app/cmd/client_test.go @@ -33,10 +33,12 @@ func TestClientConfig(t *testing.T) { }, }, TLS: clientConfigTLS{ - SNI: "another.example.com", - Insecure: true, - PinSHA256: "114515DEADBEEF", - CA: "custom_ca.crt", + SNI: "another.example.com", + Insecure: true, + PinSHA256: "114515DEADBEEF", + CA: "custom_ca.crt", + ClientCertificate: "client.crt", + ClientKey: "client.key", }, QUIC: clientConfigQUIC{ InitStreamReceiveWindow: 1145141, diff --git a/app/cmd/client_test.yaml b/app/cmd/client_test.yaml index e8438f6..eda3f80 100644 --- a/app/cmd/client_test.yaml +++ b/app/cmd/client_test.yaml @@ -17,6 +17,8 @@ tls: insecure: true pinSHA256: 114515DEADBEEF ca: custom_ca.crt + clientCertificate: client.crt + clientKey: client.key quic: initStreamReceiveWindow: 1145141 diff --git a/app/cmd/server.go b/app/cmd/server.go index a2aa9a4..e2d2a57 100644 --- a/app/cmd/server.go +++ b/app/cmd/server.go @@ -3,6 +3,7 @@ package cmd import ( "context" "crypto/tls" + "crypto/x509" "encoding/json" "errors" "fmt" @@ -86,6 +87,7 @@ type serverConfigTLS struct { Cert string `mapstructure:"cert"` Key string `mapstructure:"key"` SNIGuard string `mapstructure:"sniGuard"` // "disable", "dns-san", "strict" + ClientCA string `mapstructure:"clientCA"` } type serverConfigACME struct { @@ -333,6 +335,18 @@ func (c *serverConfig) fillTLSConfig(hyConfig *server.Config) error { // Use GetCertificate instead of Certificates so that // users can update the cert without restarting the server. hyConfig.TLSConfig.GetCertificate = certLoader.GetCertificate + // Client CA + if c.TLS.ClientCA != "" { + ca, err := os.ReadFile(c.TLS.ClientCA) + if err != nil { + return configError{Field: "tls.clientCA", Err: err} + } + cPool := x509.NewCertPool() + if !cPool.AppendCertsFromPEM(ca) { + return configError{Field: "tls.clientCA", Err: errors.New("failed to parse client CA certificate")} + } + hyConfig.TLSConfig.ClientCAs = cPool + } } else { // ACME dataDir := c.ACME.Dir diff --git a/app/cmd/server_test.go b/app/cmd/server_test.go index 5849a38..b2e3a77 100644 --- a/app/cmd/server_test.go +++ b/app/cmd/server_test.go @@ -29,6 +29,7 @@ func TestServerConfig(t *testing.T) { Cert: "some.crt", Key: "some.key", SNIGuard: "strict", + ClientCA: "some_ca.crt", }, ACME: &serverConfigACME{ Domains: []string{ diff --git a/app/cmd/server_test.yaml b/app/cmd/server_test.yaml index b989b97..0aa9e3f 100644 --- a/app/cmd/server_test.yaml +++ b/app/cmd/server_test.yaml @@ -9,6 +9,7 @@ tls: cert: some.crt key: some.key sniGuard: strict + clientCA: some_ca.crt acme: domains: diff --git a/core/client/client.go b/core/client/client.go index 3691d1e..b1326a3 100644 --- a/core/client/client.go +++ b/core/client/client.go @@ -74,6 +74,7 @@ func (c *clientImpl) connect() (*HandshakeInfo, error) { InsecureSkipVerify: c.config.TLSConfig.InsecureSkipVerify, VerifyPeerCertificate: c.config.TLSConfig.VerifyPeerCertificate, RootCAs: c.config.TLSConfig.RootCAs, + GetClientCertificate: c.config.TLSConfig.GetClientCertificate, } quicConfig := &quic.Config{ InitialStreamReceiveWindow: c.config.QUICConfig.InitialStreamReceiveWindow, diff --git a/core/client/config.go b/core/client/config.go index 7270c30..2f864d9 100644 --- a/core/client/config.go +++ b/core/client/config.go @@ -1,6 +1,7 @@ package client import ( + "crypto/tls" "crypto/x509" "net" "time" @@ -92,6 +93,7 @@ type TLSConfig struct { InsecureSkipVerify bool VerifyPeerCertificate func(rawCerts [][]byte, verifiedChains [][]*x509.Certificate) error RootCAs *x509.CertPool + GetClientCertificate func(*tls.CertificateRequestInfo) (*tls.Certificate, error) } // QUICConfig contains the QUIC configuration fields that we want to expose to the user. diff --git a/core/server/config.go b/core/server/config.go index a01f478..ecdfec9 100644 --- a/core/server/config.go +++ b/core/server/config.go @@ -2,6 +2,7 @@ package server import ( "crypto/tls" + "crypto/x509" "net" "net/http" "sync/atomic" @@ -101,6 +102,7 @@ func (c *Config) fill() error { type TLSConfig struct { Certificates []tls.Certificate GetCertificate func(info *tls.ClientHelloInfo) (*tls.Certificate, error) + ClientCAs *x509.CertPool } // QUICConfig contains the QUIC configuration fields that we want to expose to the user. diff --git a/core/server/server.go b/core/server/server.go index 89645e0..79c112d 100644 --- a/core/server/server.go +++ b/core/server/server.go @@ -26,14 +26,26 @@ type Server interface { Close() error } +func convertToStdTLSConfig(config *Config) *tls.Config { + var clientAuth tls.ClientAuthType + if config.TLSConfig.ClientCAs != nil { + clientAuth = tls.RequireAndVerifyClientCert + } else { + clientAuth = tls.NoClientCert + } + return http3.ConfigureTLSConfig(&tls.Config{ + Certificates: config.TLSConfig.Certificates, + GetCertificate: config.TLSConfig.GetCertificate, + ClientCAs: config.TLSConfig.ClientCAs, + ClientAuth: clientAuth, + }) +} + func NewServer(config *Config) (Server, error) { if err := config.fill(); err != nil { return nil, err } - tlsConfig := http3.ConfigureTLSConfig(&tls.Config{ - Certificates: config.TLSConfig.Certificates, - GetCertificate: config.TLSConfig.GetCertificate, - }) + tlsConfig := convertToStdTLSConfig(config) quicConfig := &quic.Config{ InitialStreamReceiveWindow: config.QUICConfig.InitialStreamReceiveWindow, MaxStreamReceiveWindow: config.QUICConfig.MaxStreamReceiveWindow,