Skip to content

Commit a83cb0a

Browse files
committed
Add an option to check cert hashes on every client DoH connection
1 parent aaadd1d commit a83cb0a

7 files changed

Lines changed: 107 additions & 17 deletions

File tree

‎dnscrypt-proxy/cert_hashes.go‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package main
2+
3+
import (
4+
"bytes"
5+
"crypto/sha256"
6+
"crypto/tls"
7+
"crypto/x509"
8+
"errors"
9+
"net"
10+
"slices"
11+
"strings"
12+
13+
"golang.org/x/net/idna"
14+
)
15+
16+
var errCertHashNotFound = errors.New("Certificate hash not found")
17+
18+
// certHashesMatch reports whether the key of a pinned certificate is in a verified chain.
19+
func certHashesMatch(cs *tls.ConnectionState, hashes [][]byte) bool {
20+
isPinned := func(cert *x509.Certificate) bool {
21+
h := sha256.Sum256(cert.RawTBSCertificate)
22+
return slices.ContainsFunc(hashes, func(hash []byte) bool { return bytes.Equal(hash, h[:]) })
23+
}
24+
pinnedKeys := make(map[string]bool)
25+
for _, cert := range cs.PeerCertificates {
26+
if isPinned(cert) {
27+
pinnedKeys[string(cert.RawSubjectPublicKeyInfo)] = true
28+
}
29+
}
30+
for _, chain := range cs.VerifiedChains {
31+
for _, cert := range chain {
32+
if pinnedKeys[string(cert.RawSubjectPublicKeyInfo)] || isPinned(cert) {
33+
return true
34+
}
35+
}
36+
}
37+
return false
38+
}
39+
40+
func certHashesHostKey(host string) string {
41+
if h, _, err := net.SplitHostPort(host); err == nil {
42+
host = h
43+
}
44+
if ip := ParseIP(host); ip != nil {
45+
return ip.String()
46+
}
47+
if ascii, err := idna.Lookup.ToASCII(host); err == nil {
48+
host = ascii
49+
}
50+
return strings.ToLower(strings.TrimSuffix(host, "."))
51+
}

‎dnscrypt-proxy/config.go‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ type Config struct {
9595
TLSCipherSuite []uint16 `toml:"tls_cipher_suite"`
9696
TLSPreferRSA bool `toml:"tls_prefer_rsa"`
9797
TLSKeyLogFile string `toml:"tls_key_log_file"`
98+
DoHCertHashesOnConnect bool `toml:"doh_check_cert_hashes_on_connect"`
9899
NetprobeAddress string `toml:"netprobe_address"`
99100
NetprobeTimeout int `toml:"netprobe_timeout"`
100101
OfflineMode bool `toml:"offline_mode"`
@@ -161,6 +162,7 @@ func newConfig() Config {
161162
TLSCipherSuite: nil,
162163
TLSPreferRSA: false,
163164
TLSKeyLogFile: "",
165+
DoHCertHashesOnConnect: false,
164166
NetprobeTimeout: 60,
165167
OfflineMode: false,
166168
RefusedCodeInResponses: false,

‎dnscrypt-proxy/config_loader.go‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,9 @@ func configureLogging(proxy *Proxy, flags *ConfigFlags, config *Config) {
7575
func configureXTransport(proxy *Proxy, config *Config) error {
7676
proxy.xTransport.tlsDisableSessionTickets = config.TLSDisableSessionTickets
7777
proxy.xTransport.tlsPreferRSA = config.TLSPreferRSA
78+
if config.DoHCertHashesOnConnect {
79+
proxy.xTransport.certHashes.Store(&map[string][][]byte{})
80+
}
7881
proxy.xTransport.http3 = config.HTTP3
7982
proxy.xTransport.http3Probe = config.HTTP3Probe
8083

‎dnscrypt-proxy/example-dnscrypt-proxy.toml‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,15 @@ cert_refresh_delay = 240
295295
# tls_disable_session_tickets = false
296296

297297

298+
## DoH: Check the certificate hashes of server stamps on every new connection,
299+
## not only at startup and when certificates are reloaded.
300+
##
301+
## This improves security, but also increases the risks of downtime if you
302+
## don't use DNSCrypt servers in addition to DoH servers.
303+
304+
# doh_check_cert_hashes_on_connect = false
305+
306+
298307
## Prefer RSA certificates over ECDSA for TLS connections.
299308
## When this is enabled, some servers may become impossible to use,
300309
## or may stop working later as they upgrade their configuration.

‎dnscrypt-proxy/proxy.go‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -449,6 +449,16 @@ func (proxy *Proxy) updateRegisteredServers() error {
449449
for _, registeredRelay := range proxy.registeredRelays {
450450
proxy.serversInfo.registerRelay(registeredRelay.name, registeredRelay.stamp)
451451
}
452+
if proxy.xTransport.certHashes.Load() != nil {
453+
certHashes := make(map[string][][]byte)
454+
for _, registeredServer := range proxy.registeredServers {
455+
if stamp := registeredServer.stamp; stamp.Proto == stamps.StampProtoTypeDoH {
456+
key := certHashesHostKey(stamp.ProviderName)
457+
certHashes[key] = append(certHashes[key], stamp.Hashes...)
458+
}
459+
}
460+
proxy.xTransport.certHashes.Store(&certHashes)
461+
}
452462
return nil
453463
}
454464

‎dnscrypt-proxy/serversInfo.go‎

Lines changed: 7 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -994,6 +994,10 @@ func fetchDoHServerInfo(proxy *Proxy, name string, stamp stamps.ServerStamp, isN
994994
body := dohTestPacket(0xcafe).Data
995995
useGet := false
996996
if _, _, _, _, err := proxy.xTransport.DoHQuery(useGet, url, body, proxy.timeout); err != nil {
997+
if errors.Is(err, errCertHashNotFound) {
998+
dlog.Criticalf("[%s] Certificate hash %x not found", name, stamp.Hashes)
999+
return ServerInfo{}, err
1000+
}
9971001
useGet = true
9981002
if _, _, _, _, err := proxy.xTransport.DoHQuery(useGet, url, body, proxy.timeout); err != nil {
9991003
return ServerInfo{}, err
@@ -1029,31 +1033,17 @@ func fetchDoHServerInfo(proxy *Proxy, name string, stamp stamps.ServerStamp, isN
10291033
}
10301034
dlog.Infof("[%s] TLS version: %x - Protocol: %v - Cipher suite: %v", name, tls.Version, protocol, tls.CipherSuite)
10311035
showCerts := proxy.showCerts
1032-
found := false
1033-
var wantedHash [32]byte
10341036
for _, cert := range tls.PeerCertificates {
10351037
h := sha256.Sum256(cert.RawTBSCertificate)
10361038
if showCerts {
10371039
dlog.Noticef("Advertised cert: [%s] [%x]", cert.Subject, h)
10381040
} else {
10391041
dlog.Debugf("Advertised cert: [%s] [%x]", cert.Subject, h)
10401042
}
1041-
for _, hash := range stamp.Hashes {
1042-
if len(hash) == len(wantedHash) {
1043-
copy(wantedHash[:], hash)
1044-
if h == wantedHash {
1045-
found = true
1046-
break
1047-
}
1048-
}
1049-
}
1050-
if found {
1051-
break
1052-
}
10531043
}
1054-
if !found && len(stamp.Hashes) > 0 {
1055-
dlog.Criticalf("[%s] Certificate hash [%x] not found", name, wantedHash)
1056-
return ServerInfo{}, fmt.Errorf("Certificate hash not found")
1044+
if len(stamp.Hashes) > 0 && !certHashesMatch(tls, stamp.Hashes) {
1045+
dlog.Criticalf("[%s] Certificate hash %x not found", name, stamp.Hashes)
1046+
return ServerInfo{}, errCertHashNotFound
10571047
}
10581048
if len(serverResponse) < MinDNSPacketSize || len(serverResponse) > MaxDNSPacketSize ||
10591049
serverResponse[0] != 0xca || serverResponse[1] != 0xfe || serverResponse[4] != 0x00 || serverResponse[5] != 0x01 {

‎dnscrypt-proxy/xtransport.go‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ type XTransport struct {
101101
httpProxyFunction func(*http.Request) (*url.URL, error)
102102
tlsClientCreds DOHClientCreds
103103
keyLogWriter io.Writer
104+
certHashes atomic.Pointer[map[string][][]byte]
104105
}
105106

106107
func NewXTransport() *XTransport {
@@ -397,6 +398,24 @@ func (xTransport *XTransport) rebuildTransport() {
397398
if xTransport.tlsDisableSessionTickets {
398399
tlsClientConfig.SessionTicketsDisabled = true
399400
}
401+
if xTransport.certHashes.Load() != nil {
402+
tlsClientConfig.VerifyConnection = func(cs tls.ConnectionState) error {
403+
certHashes := xTransport.certHashes.Load()
404+
var hashes [][]byte
405+
if cs.ServerName != "" {
406+
hashes = (*certHashes)[certHashesHostKey(cs.ServerName)]
407+
} else if len(cs.PeerCertificates) > 0 {
408+
// The stamp host name is an IP address, so there is no SNI: use the certificate addresses.
409+
for _, ip := range cs.PeerCertificates[0].IPAddresses {
410+
hashes = append(hashes, (*certHashes)[ip.String()]...)
411+
}
412+
}
413+
if len(hashes) > 0 && !certHashesMatch(&cs, hashes) {
414+
return errCertHashNotFound
415+
}
416+
return nil
417+
}
418+
}
400419
if xTransport.tlsPreferRSA {
401420
tlsClientConfig.MaxVersion = tls.VersionTLS12
402421
if hasAESGCMHardwareSupport {
@@ -730,6 +749,12 @@ func (xTransport *XTransport) Fetch(
730749
Transport: xTransport.transport,
731750
Timeout: timeout,
732751
}
752+
if certHashes := xTransport.certHashes.Load(); certHashes != nil && len((*certHashes)[certHashesHostKey(url.Host)]) > 0 {
753+
// The other host's certificate wouldn't be checked against these hashes
754+
client.CheckRedirect = func(*http.Request, []*http.Request) error {
755+
return errors.New("Redirects are not followed for servers with certificate hashes")
756+
}
757+
}
733758
host, port := ExtractHostAndPort(url.Host, 443)
734759
hasAltSupport := false
735760
http3Suppressed := false

0 commit comments

Comments
 (0)