Add util/fetch_ech_config_list.go

I wrote this tool to make it easier to test the ECH client against
real-world servers with the bssl client tool. I found that manually
extracting an ECHConfigList from a raw HTTPS record is unnecessarily
painful.

The tool queries DNS over UDP for HTTPS records. If it finds any HTTPS
records in the response, it attempts to extract an ECHConfigList from
the "ech" SvcParam. It can write each extracted ECHConfigList to a file
in a given directory. Once the ECH client implementation lands, the bssl
client tool should have a new flag that that takes the path to an
ECHConfigList file.

I am using golang.org/x/net/dns/dnsmessage to parse the DNS response. I
recently added the |UnknownResource| type to this library to enable
callers (like us) to extract the bytes of otherwise-unsupported records
(like HTTPS). I updated the dependency with `go get -u golang.org/x/net`.

Although the bssl client tool knows how to resolve the address of its
"-connect" parameter, it is difficult to query HTTPS records in a
platform-agnostic way. If we decide the bssl client should directly
query HTTPS rather than leaning on fetch_ech_config_list.go, we should
look into libresolv. Specifically, the |res_query| function enables the
caller to query arbitrary record types. This may open its own can of
cross-platform worms; macOS and Linux typically ship with different
implementations and it is not available on Windows. For more info, see
`man 3 resolver`.

Bug: 275
Change-Id: I705591658921f60a958164a18b68ffb697c2ea4b
Reviewed-on: https://boringssl-review.googlesource.com/c/boringssl/+/44104
Reviewed-by: David Benjamin <davidben@google.com>
diff --git a/go.mod b/go.mod
index 17f9468..e3184cb 100644
--- a/go.mod
+++ b/go.mod
@@ -2,4 +2,7 @@
 
 go 1.13
 
-require golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9
+require (
+	golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9
+	golang.org/x/net v0.0.0-20210525063256-abc453219eb5 // indirect
+)
diff --git a/go.sum b/go.sum
index 8b7d318..684fb56 100644
--- a/go.sum
+++ b/go.sum
@@ -2,7 +2,14 @@
 golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 h1:psW17arqaxU48Z5kZ0CQnkZWQJsqcURM6tKiBApRjXI=
 golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
 golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20210525063256-abc453219eb5 h1:wjuX4b5yYQnEQHzd+CBcrcC6OVR2J1CN6mUy0oSxIPo=
+golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20190412213103-97732733099d h1:+R4KGOnez64A81RvjARKc4UT5/tI9ujCIVX+P5KiHuI=
 golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
diff --git a/util/fetch_ech_config_list.go b/util/fetch_ech_config_list.go
new file mode 100644
index 0000000..03b2f87
--- /dev/null
+++ b/util/fetch_ech_config_list.go
@@ -0,0 +1,389 @@
+// Copyright (c) 2021, Google Inc.
+//
+// Permission to use, copy, modify, and/or distribute this software for any
+// purpose with or without fee is hereby granted, provided that the above
+// copyright notice and this permission notice appear in all copies.
+//
+// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY
+// SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION
+// OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN
+// CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+package main
+
+import (
+	"errors"
+	"flag"
+	"fmt"
+	"io/ioutil"
+	"log"
+	"net"
+	"os"
+	"path"
+	"strings"
+
+	"golang.org/x/crypto/cryptobyte"
+	"golang.org/x/net/dns/dnsmessage"
+)
+
+const (
+	httpsType = 65 // RRTYPE for HTTPS records.
+
+	// SvcParamKey codepoints defined in draft-ietf-dnsop-svcb-https-06.
+	httpsKeyMandatory     = 0
+	httpsKeyALPN          = 1
+	httpsKeyNoDefaultALPN = 2
+	httpsKeyPort          = 3
+	httpsKeyIPV4Hint      = 4
+	httpsKeyECH           = 5
+	httpsKeyIPV6Hint      = 6
+)
+
+var (
+	name   = flag.String("name", "", "The name to look up in DNS. Required.")
+	server = flag.String("server", "8.8.8.8:53", "Comma-separated host and UDP port that defines the DNS server to query.")
+	outDir = flag.String("out-dir", "", "The directory where ECHConfigList values will be written. If unspecified, bytes are hexdumped to stdout.")
+)
+
+type httpsRecord struct {
+	priority   uint16
+	targetName string
+
+	// SvcParams:
+	mandatory     []uint16
+	alpn          []string
+	noDefaultALPN bool
+	hasPort       bool
+	port          uint16
+	ipv4hint      []net.IP
+	ech           []byte
+	ipv6hint      []net.IP
+	unknownParams map[uint16][]byte
+}
+
+// String pretty-prints |h| as a multi-line string with bullet points.
+func (h httpsRecord) String() string {
+	var b strings.Builder
+	fmt.Fprintf(&b, "HTTPS SvcPriority:%d TargetName:%q", h.priority, h.targetName)
+
+	if len(h.mandatory) != 0 {
+		fmt.Fprintf(&b, "\n  * mandatory: %v", h.mandatory)
+	}
+	if len(h.alpn) != 0 {
+		fmt.Fprintf(&b, "\n  * alpn: %q", h.alpn)
+	}
+	if h.noDefaultALPN {
+		fmt.Fprint(&b, "\n  * no-default-alpn")
+	}
+	if h.hasPort {
+		fmt.Fprintf(&b, "\n  * port: %d", h.port)
+	}
+	if len(h.ipv4hint) != 0 {
+		fmt.Fprintf(&b, "\n  * ipv4hint:")
+		for _, address := range h.ipv4hint {
+			fmt.Fprintf(&b, "\n    - %s", address)
+		}
+	}
+	if len(h.ech) != 0 {
+		fmt.Fprintf(&b, "\n  * ech: %x", h.ech)
+	}
+	if len(h.ipv6hint) != 0 {
+		fmt.Fprintf(&b, "\n  * ipv6hint:")
+		for _, address := range h.ipv6hint {
+			fmt.Fprintf(&b, "\n    - %s", address)
+		}
+	}
+	if len(h.unknownParams) != 0 {
+		fmt.Fprint(&b, "\n  * unknown SvcParams:")
+		for key, value := range h.unknownParams {
+			fmt.Fprintf(&b, "\n    - %d: %x", key, value)
+		}
+	}
+	return b.String()
+}
+
+// dnsQueryForHTTPS queries the DNS server over UDP for any HTTPS records
+// associated with |domain|. It scans the response's answers and returns all the
+// HTTPS records it finds. It returns an error if any connection steps fail.
+func dnsQueryForHTTPS(domain string) ([][]byte, error) {
+	udpAddr, err := net.ResolveUDPAddr("udp", *server)
+	if err != nil {
+		return nil, err
+	}
+	conn, err := net.DialUDP("udp", nil, udpAddr)
+	if err != nil {
+		return nil, fmt.Errorf("failed to dial: %s", err)
+	}
+	defer conn.Close()
+
+	// Domain name must be canonical or message packing will fail.
+	if domain[len(domain)-1] != '.' {
+		domain += "."
+	}
+	dnsName, err := dnsmessage.NewName(domain)
+	if err != nil {
+		return nil, fmt.Errorf("failed to create DNS name from %q: %s", domain, err)
+	}
+	question := dnsmessage.Question{
+		Name:  dnsName,
+		Type:  httpsType,
+		Class: dnsmessage.ClassINET,
+	}
+	msg := dnsmessage.Message{
+		Header: dnsmessage.Header{
+			RecursionDesired: true,
+		},
+		Questions: []dnsmessage.Question{question},
+	}
+	packedMsg, err := msg.Pack()
+	if err != nil {
+		return nil, fmt.Errorf("failed to pack msg: %s", err)
+	}
+
+	if _, err = conn.Write(packedMsg); err != nil {
+		return nil, fmt.Errorf("failed to send the DNS query: %s", err)
+	}
+
+	for {
+		response := make([]byte, 512)
+		n, err := conn.Read(response)
+		if err != nil {
+			return nil, fmt.Errorf("failed to read the DNS response: %s", err)
+		}
+		response = response[:n]
+
+		var p dnsmessage.Parser
+		header, err := p.Start(response)
+		if err != nil {
+			return nil, err
+		}
+		if !header.Response {
+			return nil, errors.New("received DNS message is not a response")
+		}
+		if header.RCode != dnsmessage.RCodeSuccess {
+			return nil, fmt.Errorf("response from DNS has non-success RCode: %s", header.RCode.String())
+		}
+		if header.ID != 0 {
+			return nil, errors.New("received a DNS response with the wrong ID")
+		}
+		if !header.RecursionAvailable {
+			return nil, errors.New("server does not support recursion")
+		}
+		// Verify that this response answers the question that we asked in the
+		// query. If the resolver encountered any CNAMEs, it's not guaranteed
+		// that the response will contain a question with the same QNAME as our
+		// query. However, RFC8499 Section 4 indicates that in general use, the
+		// response's QNAME should match the query, so we will make that
+		// assumption.
+		q, err := p.Question()
+		if err != nil {
+			return nil, err
+		}
+		if q != question {
+			return nil, fmt.Errorf("response answers the wrong question: %v", q)
+		}
+		if q, err = p.Question(); err != dnsmessage.ErrSectionDone {
+			return nil, fmt.Errorf("response contains an unexpected question: %v", q)
+		}
+
+		var httpsRecords [][]byte
+		for {
+			h, err := p.AnswerHeader()
+			if err == dnsmessage.ErrSectionDone {
+				break
+			}
+			if err != nil {
+				return nil, err
+			}
+
+			switch h.Type {
+			case httpsType:
+				// This should continue to work when golang.org/x/net/dns/dnsmessage
+				// adds support for HTTPS records.
+				r, err := p.UnknownResource()
+				if err != nil {
+					return nil, err
+				}
+				httpsRecords = append(httpsRecords, r.Data)
+			default:
+				if _, err := p.UnknownResource(); err != nil {
+					return nil, err
+				}
+			}
+		}
+		return httpsRecords, nil
+	}
+}
+
+// parseHTTPSRecord parses an HTTPS record (draft-ietf-dnsop-svcb-https-06,
+// Section 2.2) from |raw|. If there are syntax errors, it returns an error.
+func parseHTTPSRecord(raw []byte) (httpsRecord, error) {
+	reader := cryptobyte.String(raw)
+
+	var priority uint16
+	if !reader.ReadUint16(&priority) {
+		return httpsRecord{}, errors.New("failed to parse HTTPS record priority")
+	}
+
+	// Read the TargetName.
+	var dottedDomain string
+	for {
+		var label cryptobyte.String
+		if !reader.ReadUint8LengthPrefixed(&label) {
+			return httpsRecord{}, errors.New("failed to parse HTTPS record TargetName")
+		}
+		if label.Empty() {
+			break
+		}
+		dottedDomain += string(label) + "."
+	}
+
+	if priority == 0 {
+		// TODO(dmcardle) Recursively follow AliasForm records.
+		return httpsRecord{}, fmt.Errorf("received an AliasForm HTTPS record with TargetName=%q", dottedDomain)
+	}
+
+	record := httpsRecord{
+		priority:      priority,
+		targetName:    dottedDomain,
+		unknownParams: make(map[uint16][]byte),
+	}
+
+	// Read the SvcParams.
+	var lastSvcParamKey uint16
+	for svcParamCount := 0; !reader.Empty(); svcParamCount++ {
+		var svcParamKey uint16
+		var svcParamValue cryptobyte.String
+		if !reader.ReadUint16(&svcParamKey) ||
+			!reader.ReadUint16LengthPrefixed(&svcParamValue) {
+			return httpsRecord{}, errors.New("failed to parse HTTPS record SvcParam")
+		}
+		if svcParamCount > 0 && svcParamKey <= lastSvcParamKey {
+			return httpsRecord{}, errors.New("malformed HTTPS record contains out-of-order SvcParamKey")
+		}
+		lastSvcParamKey = svcParamKey
+
+		switch svcParamKey {
+		case httpsKeyMandatory:
+			if svcParamValue.Empty() {
+				return httpsRecord{}, errors.New("malformed mandatory SvcParamValue")
+			}
+			var lastKey uint16
+			for !svcParamValue.Empty() {
+				// |httpsKeyMandatory| may not appear in the mandatory list.
+				// |httpsKeyMandatory| is zero, so checking against the initial
+				// value of |lastKey| handles ordering and the invalid code point.
+				var key uint16
+				if !svcParamValue.ReadUint16(&key) ||
+					key <= lastKey {
+					return httpsRecord{}, errors.New("malformed mandatory SvcParamValue")
+				}
+				lastKey = key
+				record.mandatory = append(record.mandatory, key)
+			}
+		case httpsKeyALPN:
+			if svcParamValue.Empty() {
+				return httpsRecord{}, errors.New("malformed alpn SvcParamValue")
+			}
+			for !svcParamValue.Empty() {
+				var alpn cryptobyte.String
+				if !svcParamValue.ReadUint8LengthPrefixed(&alpn) || alpn.Empty() {
+					return httpsRecord{}, errors.New("malformed alpn SvcParamValue")
+				}
+				record.alpn = append(record.alpn, string(alpn))
+			}
+		case httpsKeyNoDefaultALPN:
+			if !svcParamValue.Empty() {
+				return httpsRecord{}, errors.New("malformed no-default-alpn SvcParamValue")
+			}
+			record.noDefaultALPN = true
+		case httpsKeyPort:
+			if !svcParamValue.ReadUint16(&record.port) ||
+				!svcParamValue.Empty() {
+				return httpsRecord{}, errors.New("malformed port SvcParamValue")
+			}
+			record.hasPort = true
+		case httpsKeyIPV4Hint:
+			if svcParamValue.Empty() {
+				return httpsRecord{}, errors.New("malformed ipv4hint SvcParamValue")
+			}
+			for !svcParamValue.Empty() {
+				var address []byte
+				if !svcParamValue.ReadBytes(&address, 4) {
+					return httpsRecord{}, errors.New("malformed ipv4hint SvcParamValue")
+				}
+				record.ipv4hint = append(record.ipv4hint, address)
+			}
+		case httpsKeyECH:
+			if svcParamValue.Empty() {
+				return httpsRecord{}, errors.New("malformed ech SvcParamValue")
+			}
+			record.ech = svcParamValue
+		case httpsKeyIPV6Hint:
+			if svcParamValue.Empty() {
+				return httpsRecord{}, errors.New("malformed ipv6hint SvcParamValue")
+			}
+			for !svcParamValue.Empty() {
+				var address []byte
+				if !svcParamValue.ReadBytes(&address, 16) {
+					return httpsRecord{}, errors.New("malformed ipv6hint SvcParamValue")
+				}
+				record.ipv6hint = append(record.ipv6hint, address)
+			}
+		default:
+			record.unknownParams[svcParamKey] = svcParamValue
+		}
+	}
+	return record, nil
+}
+
+func main() {
+	flag.Parse()
+	log.SetFlags(log.Lshortfile | log.LstdFlags)
+
+	if len(*name) == 0 {
+		flag.Usage()
+		os.Exit(1)
+	}
+
+	httpsRecords, err := dnsQueryForHTTPS(*name)
+	if err != nil {
+		log.Printf("Error querying %q: %s\n", *name, err)
+		os.Exit(1)
+	}
+	if len(httpsRecords) == 0 {
+		log.Println("No HTTPS records found in DNS response.")
+		os.Exit(1)
+	}
+
+	if len(*outDir) > 0 {
+		if err = os.Mkdir(*outDir, 0755); err != nil && !os.IsExist(err) {
+			log.Printf("Failed to create out directory %q: %s\n", *outDir, err)
+			os.Exit(1)
+		}
+	}
+
+	var echConfigListCount int
+	for _, httpsRecord := range httpsRecords {
+		record, err := parseHTTPSRecord(httpsRecord)
+		if err != nil {
+			log.Printf("Failed to parse HTTPS record: %s", err)
+			os.Exit(1)
+		}
+		fmt.Printf("%s\n", record)
+		if len(*outDir) == 0 {
+			continue
+		}
+
+		outFile := path.Join(*outDir, fmt.Sprintf("ech-config-list-%d", echConfigListCount))
+		if err = ioutil.WriteFile(outFile, record.ech, 0644); err != nil {
+			log.Printf("Failed to write file: %s\n", err)
+			os.Exit(1)
+		}
+		fmt.Printf("Wrote ECHConfigList to %q\n", outFile)
+		echConfigListCount++
+	}
+}