From 3c3c2a51a870e7bc308772d4289589ba76d38dc7 Mon Sep 17 00:00:00 2001 From: Toby Date: Wed, 23 Aug 2023 15:53:22 -0700 Subject: [PATCH] feat: client TLS cert SHA256 pinning (pinSHA256) --- app/cmd/client.go | 30 +++++++++++++++++++++++++++--- app/cmd/client_test.go | 14 ++++++++------ app/cmd/client_test.yaml | 1 + app/cmd/utils.go | 10 ++++++++++ core/client/client.go | 7 ++++--- core/client/config.go | 7 ++++--- 6 files changed, 54 insertions(+), 15 deletions(-) diff --git a/app/cmd/client.go b/app/cmd/client.go index 8fc986b..42199d7 100644 --- a/app/cmd/client.go +++ b/app/cmd/client.go @@ -1,7 +1,9 @@ package cmd import ( + "crypto/sha256" "crypto/x509" + "encoding/hex" "errors" "net" "net/url" @@ -70,9 +72,10 @@ type clientConfigObfs struct { } type clientConfigTLS struct { - SNI string `mapstructure:"sni"` - Insecure bool `mapstructure:"insecure"` - CA string `mapstructure:"ca"` + SNI string `mapstructure:"sni"` + Insecure bool `mapstructure:"insecure"` + PinSHA256 string `mapstructure:"pinSHA256"` + CA string `mapstructure:"ca"` } type clientConfigQUIC struct { @@ -174,6 +177,20 @@ func (c *clientConfig) fillTLSConfig(hyConfig *client.Config) error { hyConfig.TLSConfig.ServerName = c.TLS.SNI } hyConfig.TLSConfig.InsecureSkipVerify = c.TLS.Insecure + if c.TLS.PinSHA256 != "" { + nHash := normalizeCertHash(c.TLS.PinSHA256) + hyConfig.TLSConfig.VerifyPeerCertificate = func(rawCerts [][]byte, _ [][]*x509.Certificate) error { + for _, cert := range rawCerts { + hash := sha256.Sum256(cert) + hashHex := hex.EncodeToString(hash[:]) + if hashHex == nHash { + return nil + } + } + // No match + return errors.New("no certificate matches the pinned hash") + } + } if c.TLS.CA != "" { ca, err := os.ReadFile(c.TLS.CA) if err != nil { @@ -233,6 +250,7 @@ func (c *clientConfig) fillFastOpen(hyConfig *client.Config) error { // - obfuscation password // - TLS SNI // - TLS insecure +// - TLS pinned SHA256 hash (normalized) func (c *clientConfig) URI() string { q := url.Values{} switch strings.ToLower(c.Obfs.Type) { @@ -246,6 +264,9 @@ func (c *clientConfig) URI() string { if c.TLS.Insecure { q.Set("insecure", "1") } + if c.TLS.PinSHA256 != "" { + q.Set("pinSHA256", normalizeCertHash(c.TLS.PinSHA256)) + } var user *url.Userinfo if c.Auth != "" { // We need to handle the special case of user:pass pairs @@ -297,6 +318,9 @@ func (c *clientConfig) parseURI() bool { if insecure, err := strconv.ParseBool(q.Get("insecure")); err == nil { c.TLS.Insecure = insecure } + if pinSHA256 := q.Get("pinSHA256"); pinSHA256 != "" { + c.TLS.PinSHA256 = pinSHA256 + } return true } diff --git a/app/cmd/client_test.go b/app/cmd/client_test.go index ecbf9ce..1305a88 100644 --- a/app/cmd/client_test.go +++ b/app/cmd/client_test.go @@ -27,9 +27,10 @@ func TestClientConfig(t *testing.T) { }, }, TLS: clientConfigTLS{ - SNI: "another.example.com", - Insecure: true, - CA: "custom_ca.crt", + SNI: "another.example.com", + Insecure: true, + PinSHA256: "114515DEADBEEF", + CA: "custom_ca.crt", }, QUIC: clientConfigQUIC{ InitStreamReceiveWindow: 1145141, @@ -105,7 +106,7 @@ func TestClientConfigURI(t *testing.T) { }, }, { - uri: "hysteria2://noauth.com/?insecure=1&obfs=salamander&obfs-password=66ccff&sni=crap.cc", + uri: "hysteria2://noauth.com/?insecure=1&obfs=salamander&obfs-password=66ccff&pinSHA256=deadbeef&sni=crap.cc", uriOK: true, config: &clientConfig{ Server: "noauth.com", @@ -117,8 +118,9 @@ func TestClientConfigURI(t *testing.T) { }, }, TLS: clientConfigTLS{ - SNI: "crap.cc", - Insecure: true, + SNI: "crap.cc", + Insecure: true, + PinSHA256: "deadbeef", }, }, }, diff --git a/app/cmd/client_test.yaml b/app/cmd/client_test.yaml index 9b56c25..36696f2 100644 --- a/app/cmd/client_test.yaml +++ b/app/cmd/client_test.yaml @@ -10,6 +10,7 @@ obfs: tls: sni: another.example.com insecure: true + pinSHA256: 114515DEADBEEF ca: custom_ca.crt quic: diff --git a/app/cmd/utils.go b/app/cmd/utils.go index 3eaff48..516340a 100644 --- a/app/cmd/utils.go +++ b/app/cmd/utils.go @@ -5,6 +5,7 @@ import ( "io" "net/http" "os" + "strings" "github.com/apernet/hysteria/extras/utils" "github.com/mdp/qrterminal/v3" @@ -108,3 +109,12 @@ func (l *geoipLoader) Load() *geoip2.Reader { } return l.db } + +// normalizeCertHash normalizes a certificate hash string. +// It converts all characters to lowercase and removes possible separators such as ":" and "-". +func normalizeCertHash(hash string) string { + r := strings.ToLower(hash) + r = strings.ReplaceAll(r, ":", "") + r = strings.ReplaceAll(r, "-", "") + return r +} diff --git a/core/client/client.go b/core/client/client.go index 35da850..21d3f7b 100644 --- a/core/client/client.go +++ b/core/client/client.go @@ -63,9 +63,10 @@ func (c *clientImpl) connect() error { } // Convert config to TLS config & QUIC config tlsConfig := &tls.Config{ - ServerName: c.config.TLSConfig.ServerName, - InsecureSkipVerify: c.config.TLSConfig.InsecureSkipVerify, - RootCAs: c.config.TLSConfig.RootCAs, + ServerName: c.config.TLSConfig.ServerName, + InsecureSkipVerify: c.config.TLSConfig.InsecureSkipVerify, + VerifyPeerCertificate: c.config.TLSConfig.VerifyPeerCertificate, + RootCAs: c.config.TLSConfig.RootCAs, } quicConfig := &quic.Config{ InitialStreamReceiveWindow: c.config.QUICConfig.InitialStreamReceiveWindow, diff --git a/core/client/config.go b/core/client/config.go index d6f366b..41a72ba 100644 --- a/core/client/config.go +++ b/core/client/config.go @@ -88,9 +88,10 @@ func (f *udpConnFactory) New(addr net.Addr) (net.PacketConn, error) { // TLSConfig contains the TLS configuration fields that we want to expose to the user. type TLSConfig struct { - ServerName string - InsecureSkipVerify bool - RootCAs *x509.CertPool + ServerName string + InsecureSkipVerify bool + VerifyPeerCertificate func(rawCerts [][]byte, verifiedChains [][]*x509.Certificate) error + RootCAs *x509.CertPool } // QUICConfig contains the QUIC configuration fields that we want to expose to the user.