diff --git a/CHANGELOG b/CHANGELOG index d2b7515..31bae40 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,6 +1,8 @@ -doh and -dot mode use uTLS to camouflage their TLS Client Hello -fingerprint. This change means that it is no longer possible to use a -proxy in -doh mode by setting the HTTP_PROXY or HTTPS_PROXY environment +fingerprint. The fingerprint to use is chosen randomly from a weighted +distribution. You can control this distribution using the new -utls +option. This change means that it is no longer possible to use a proxy +in -doh mode by setting the HTTP_PROXY or HTTPS_PROXY environment variables; this was an undocumented side effect of using the Go net/http package with no TLS camouflage. diff --git a/README b/README index f55fc99..3706cae 100644 --- a/README +++ b/README @@ -284,7 +284,18 @@ tunnel server is acting as a proxy, for example), unless that data has been separately encrypted before being sent through the tunnel. dnstt-client disguises its TLS fingerprint using uTLS -(https://github.com/refraction-networking/utls). +(https://github.com/refraction-networking/utls). By default, a specific +TLS Client Hello fingerprint is selected randomly from a weighted +distribution. You can control the distribution of fingerprints (or +select a specific single fingerprint) using the `-utls` option. The +syntax of the option's argument is a comma-separated list of fingerprint +names, each optionally preceded by an integer weight and `*`. +``` +$ ./dnstt-client -utls '3*Firefox,2*Chrome,1*iOS' ... +$ ./dnstt-client -utls Firefox ... +``` +Run `./dnstt-client -help` to see the available fingerprint names and +the default distribution. ## Encryption and authentication diff --git a/dnstt-client/http.go b/dnstt-client/http.go index 4ef34a8..560735a 100644 --- a/dnstt-client/http.go +++ b/dnstt-client/http.go @@ -18,13 +18,6 @@ import ( // header in an HTTP response. const defaultRetryAfter = 10 * time.Second -// The *http.Client shared by instances of HTTPPacketConn. We use this instead -// of http.DefaultClient in order to set a timeout and a uTLS fingerprint. -var httpClient = &http.Client{ - Transport: NewUTLSRoundTripper(nil, utlsClientHelloID), - Timeout: 1 * time.Minute, -} - // HTTPPacketConn is an HTTP-based transport for DNS messages, used for DNS over // HTTPS (DoH). Its WriteTo and ReadFrom methods exchange DNS messages over HTTP // requests and responses. @@ -35,6 +28,11 @@ var httpClient = &http.Client{ // // https://tools.ietf.org/html/rfc8484 type HTTPPacketConn struct { + // client is the http.Client used to make requests. We use this instead + // of http.DefaultClient in order to support setting a timeout and a + // uTLS fingerprint. + client *http.Client + // urlString is the URL to which HTTP requests will be sent, for example // "https://doh.example/dns-query". urlString string @@ -57,11 +55,16 @@ type HTTPPacketConn struct { } // NewHTTPPacketConn creates a new HTTPPacketConn configured to use the HTTP -// server at urlString as a DNS over HTTP resolver. urlString should include any -// necessary path components; e.g., "/dns-query". numSenders is the number of -// concurrent sender-receiver goroutines to run. -func NewHTTPPacketConn(urlString string, numSenders int) (*HTTPPacketConn, error) { +// server at urlString as a DNS over HTTP resolver. client is the http.Client +// that will be used to make requests. urlString should include any necessary +// path components; e.g., "/dns-query". numSenders is the number of concurrent +// sender-receiver goroutines to run. +func NewHTTPPacketConn(rt http.RoundTripper, urlString string, numSenders int) (*HTTPPacketConn, error) { c := &HTTPPacketConn{ + client: &http.Client{ + Transport: rt, + Timeout: 1 * time.Minute, + }, urlString: urlString, QueuePacketConn: turbotunnel.NewQueuePacketConn(turbotunnel.DummyAddr{}, 0), } @@ -81,7 +84,7 @@ func (c *HTTPPacketConn) send(p []byte) error { req.Header.Set("Accept", "application/dns-message") req.Header.Set("Content-Type", "application/dns-message") req.Header.Set("User-Agent", "") // Disable default "Go-http-client/1.1". - resp, err := httpClient.Do(req) + resp, err := c.client.Do(req) if err != nil { return err } diff --git a/dnstt-client/main.go b/dnstt-client/main.go index deb5de0..3a36023 100644 --- a/dnstt-client/main.go +++ b/dnstt-client/main.go @@ -23,9 +23,17 @@ // // LOCALADDR is the TCP address that will listen for connections and forward // them over the tunnel. +// +// In -doh and -dot modes, the program's TLS fingerprint is camouflaged with +// uTLS. By default, the specific TLS fingerprint is selected randomly from a +// weighted distribution. You can set your own distribution (or specific single +// fingerprint) using the -utls option: +// -utls '3*Firefox,2*Chrome,1*iOS' +// -utls Firefox package main import ( + "context" "errors" "flag" "fmt" @@ -33,6 +41,7 @@ import ( "log" "net" "os" + "strings" "sync" "time" @@ -47,9 +56,6 @@ import ( // smux streams will be closed after this much time without receiving data. const idleTimeout = 2 * time.Minute -// uTLS Client Hello fingerprint to use in all TLS connections. -var utlsClientHelloID = &utls.HelloFirefox_Auto - // dnsNameCapacity returns the number of bytes remaining for encoded data after // including domain in a DNS name. func dnsNameCapacity(domain dns.Name) int { @@ -80,6 +86,26 @@ func readKeyFromFile(filename string) ([]byte, error) { return noise.ReadKey(f) } +// sampleUTLSDistribution parses a weighted uTLS Client Hello ID distribution +// string of the form "3*Firefox,2*Chrome,1*iOS", matches each label to a +// utls.ClientHelloID from utlsClientHelloIDMap, and randomly samples one +// utls.ClientHelloID from the distribution. +func sampleUTLSDistribution(spec string) (*utls.ClientHelloID, error) { + weights, labels, err := parseWeightedList(spec) + if err != nil { + return nil, err + } + ids := make([]*utls.ClientHelloID, 0, len(labels)) + for _, label := range labels { + id := utlsLookup(label) + if id == nil { + return nil, fmt.Errorf("unknown TLS fingerprint %q", label) + } + ids = append(ids, id) + } + return ids[sampleWeighted(weights)], nil +} + func handle(local *net.TCPConn, sess *smux.Session, conv uint32) error { stream, err := sess.OpenStream() if err != nil { @@ -204,6 +230,7 @@ func main() { var pubkeyFilename string var pubkeyString string var udpAddr string + var utlsDistribution string flag.Usage = func() { fmt.Fprintf(flag.CommandLine.Output(), `Usage: @@ -215,12 +242,35 @@ Examples: `, os.Args[0]) flag.PrintDefaults() + labels := make([]string, 0, len(utlsClientHelloIDMap)) + for _, entry := range utlsClientHelloIDMap { + labels = append(labels, entry.Label) + } + fmt.Fprintf(flag.CommandLine.Output(), ` +Known TLS fingerprints for -utls are: +`) + i := 0 + for i < len(labels) { + var line strings.Builder + fmt.Fprintf(&line, " %s", labels[i]) + w := 2 + len(labels[i]) + i++ + for i < len(labels) && w+1+len(labels[i]) <= 72 { + fmt.Fprintf(&line, " %s", labels[i]) + w += 1 + len(labels[i]) + i++ + } + fmt.Fprintln(flag.CommandLine.Output(), line.String()) + } } flag.StringVar(&dohURL, "doh", "", "URL of DoH resolver") flag.StringVar(&dotAddr, "dot", "", "address of DoT resolver") flag.StringVar(&pubkeyString, "pubkey", "", fmt.Sprintf("server public key (%d hex digits)", noise.KeyLen*2)) flag.StringVar(&pubkeyFilename, "pubkey-file", "", "read server public key from file") flag.StringVar(&udpAddr, "udp", "", "address of UDP DNS resolver") + flag.StringVar(&utlsDistribution, "utls", + "3*Firefox_65,1*Firefox_63,3*Chrome_83,1*Chrome_72,1*iOS_12_1", + "choose TLS fingerprint from weighted distribution") flag.Parse() log.SetFlags(log.LstdFlags | log.LUTC) @@ -264,6 +314,13 @@ Examples: os.Exit(1) } + utlsClientHelloID, err := sampleUTLSDistribution(utlsDistribution) + if err != nil { + fmt.Fprintf(os.Stderr, "parsing -utls: %v\n", err) + os.Exit(1) + } + log.Printf("uTLS fingerprint %s %s", utlsClientHelloID.Client, utlsClientHelloID.Version) + // Iterate over the remote resolver address options and select one and // only one. var remoteAddr net.Addr @@ -275,13 +332,16 @@ Examples: // -doh {dohURL, func(s string) (net.Addr, net.PacketConn, error) { addr := turbotunnel.DummyAddr{} - pconn, err := NewHTTPPacketConn(dohURL, 32) + pconn, err := NewHTTPPacketConn(NewUTLSRoundTripper(nil, utlsClientHelloID), dohURL, 32) return addr, pconn, err }}, // -dot {dotAddr, func(s string) (net.Addr, net.PacketConn, error) { addr := turbotunnel.DummyAddr{} - pconn, err := NewTLSPacketConn(dotAddr) + dialTLSContext := func(ctx context.Context, network, addr string) (net.Conn, error) { + return utlsDialContext(ctx, network, addr, nil, utlsClientHelloID) + } + pconn, err := NewTLSPacketConn(dotAddr, dialTLSContext) return addr, pconn, err }}, // -udp diff --git a/dnstt-client/tls.go b/dnstt-client/tls.go index 8490217..8897237 100644 --- a/dnstt-client/tls.go +++ b/dnstt-client/tls.go @@ -10,7 +10,6 @@ import ( "sync" "time" - utls "github.com/refraction-networking/utls" "www.bamsoftware.com/git/dnstt.git/turbotunnel" ) @@ -37,11 +36,11 @@ type TLSPacketConn struct { // server at addr as a DNS over TLS resolver. It maintains a TLS connection to // the resolver, reconnecting as necessary. It closes the connection if any // reconnection attempt fails. -func NewTLSPacketConn(addr string) (*TLSPacketConn, error) { - dial := func() (*utls.UConn, error) { +func NewTLSPacketConn(addr string, dialTLSContext func(ctx context.Context, network, addr string) (net.Conn, error)) (*TLSPacketConn, error) { + dial := func() (net.Conn, error) { ctx, cancel := context.WithTimeout(context.Background(), dialTimeout) defer cancel() - return utlsDialContext(ctx, "tcp", addr, nil, utlsClientHelloID) + return dialTLSContext(ctx, "tcp", addr) } // We maintain one TLS connection at a time, redialing it whenever it // becomes disconnected. We do the first dial here, outside the diff --git a/dnstt-client/utls.go b/dnstt-client/utls.go index 4efa209..546f3a9 100644 --- a/dnstt-client/utls.go +++ b/dnstt-client/utls.go @@ -9,12 +9,46 @@ import ( "net" "net/http" "net/url" + "strings" "sync" utls "github.com/refraction-networking/utls" "golang.org/x/net/http2" ) +// utlsClientHelloIDMap is a correspondence between human-readable labels and +// supported utls.ClientHelloIDs. +var utlsClientHelloIDMap = []struct { + Label string + ID *utls.ClientHelloID +}{ + {"Firefox", &utls.HelloFirefox_Auto}, + {"Firefox_55", &utls.HelloFirefox_55}, + {"Firefox_56", &utls.HelloFirefox_56}, + {"Firefox_63", &utls.HelloFirefox_63}, + {"Firefox_65", &utls.HelloFirefox_65}, + {"Chrome", &utls.HelloChrome_Auto}, + {"Chrome_58", &utls.HelloChrome_58}, + {"Chrome_62", &utls.HelloChrome_62}, + {"Chrome_70", &utls.HelloChrome_70}, + {"Chrome_72", &utls.HelloChrome_72}, + {"Chrome_83", &utls.HelloChrome_83}, + {"iOS", &utls.HelloIOS_Auto}, + {"iOS_11_1", &utls.HelloIOS_11_1}, + {"iOS_12_1", &utls.HelloIOS_12_1}, +} + +// utlsLookup returns a *utls.ClientHelloID from utlsClientHelloIDMap by a +// case-insensitive label match, or nil if there is no match. +func utlsLookup(label string) *utls.ClientHelloID { + for _, entry := range utlsClientHelloIDMap { + if strings.ToLower(label) == strings.ToLower(entry.Label) { + return entry.ID + } + } + return nil +} + // utlsDialContext connects to the given network address and initiates a TLS // handshake with the provided ClientHelloID, and returns the resulting TLS // connection. diff --git a/dnstt-client/weightedlist.go b/dnstt-client/weightedlist.go new file mode 100644 index 0000000..2c96ea6 --- /dev/null +++ b/dnstt-client/weightedlist.go @@ -0,0 +1,201 @@ +package main + +// Random selection from weighted distributions, and strings for specifying such +// distributions. + +import ( + cryptorand "crypto/rand" + "encoding/binary" + "fmt" + mathrand "math/rand" + "strconv" + "strings" +) + +// parseWeightedList parses a list of text labels with optional numeric weights, +// and returns parallel slices of weights and labels. If a weight is omitted for +// a label, the weight is 1. +// +// An example weighted list string is "2*apple,orange,10*cookie". This example +// results in the slices [2, 1, 10] and ["apple", "orange", "cookie"]. +// Bytes may be escaped by backslashes. +// +// list ::= entry [ "," entry ] ; +// entry ::= [ weight, "*" ] label ; +func parseWeightedList(s string) ([]uint32, []string, error) { + const ( + kindEOF = iota + kindComma + kindAsterisk + kindText + kindError + ) + type token struct { + Kind int + Text string + } + + var i int + // nextToken incrementally consumes s and returns tokens. + nextToken := func() token { + if !(i < len(s)) { + return token{Kind: kindEOF} + } + if s[i] == ',' { + i++ + return token{Kind: kindComma} + } + if s[i] == '*' { + i++ + return token{Kind: kindAsterisk} + } + var text strings.Builder + for i < len(s) && s[i] != ',' && s[i] != '*' { + if s[i] == '\\' { + i++ + if !(i < len(s)) { + return token{Kind: kindError, Text: fmt.Sprintf("%q at end of string", s[i])} + } + } + text.WriteByte(s[i]) + i++ + } + return token{Kind: kindText, Text: text.String()} + } + peekToken := func() token { + saved := i + t := nextToken() + i = saved + return t + } + + const ( + stateBeginEntry = iota + stateLabel + stateEndEntry + stateDone + stateUnexpected + ) + + var weights []uint32 + var labels []string + var weightString, label string + var t token + for state := stateBeginEntry; state != stateDone; { + switch state { + // Beginning of a new entry (at the beginning of the input or + // after a comma). + case stateBeginEntry: + t = nextToken() + switch t.Kind { + case kindText: + // If the next token is an asterisk, this text + // represents a weight; otherwise it represents + // a label (with a weight of "1"). + switch peekToken().Kind { + case kindAsterisk: + nextToken() // Consume the asterisk token. + weightString = t.Text + state = stateLabel + default: + weightString = "1" + label = t.Text + state = stateEndEntry + } + default: + state = stateUnexpected + } + // weightString is assigned and we have seen an asterisk, now + // expect a text label. + case stateLabel: + t = nextToken() + switch t.Kind { + case kindText: + label = t.Text + state = stateEndEntry + default: + state = stateUnexpected + } + // weightString and label are assigned, now emit the entry and + // expect a comma or EOF. + case stateEndEntry: + w, err := strconv.ParseUint(weightString, 10, 32) + if err != nil { + return nil, nil, err + } + weights = append(weights, uint32(w)) + labels = append(labels, label) + t = nextToken() + switch t.Kind { + case kindEOF: + state = stateDone + case kindComma: + state = stateBeginEntry + default: + state = stateUnexpected + } + case stateUnexpected: + if t.Kind == kindError { + return nil, nil, fmt.Errorf("%s", t.Text) + } else { + var ttext string + switch t.Kind { + case kindEOF: + ttext = "end of string" + case kindComma: + ttext = "\",\"" + case kindAsterisk: + ttext = "\"*\"" + case kindText: + ttext = fmt.Sprintf("%+q", t.Text) + } + return nil, nil, fmt.Errorf("unexpected %s", ttext) + } + default: + panic(state) + } + } + + return weights, labels, nil +} + +// cryptoSource is a math/rand Source that reads from the crypto/rand Reader. +// The Seed method does not affect the sequence of numbers returned from the +// Int63 method. +type cryptoSource struct{} + +func (s cryptoSource) Seed(_ int64) {} + +func (s cryptoSource) Int63() int64 { + var n int64 + err := binary.Read(cryptorand.Reader, binary.BigEndian, &n) + if err != nil { + panic(err) + } + n &= (1 << 63) - 1 + return n +} + +// sampleWeighted returns the index of a randomly selected element of the +// weights slice, weighted by the values stored in the slice. Panics if +// the sum of the weights is zero or does not fit in an int64. +func sampleWeighted(weights []uint32) int { + var sum int64 = 0 + for _, w := range weights { + sum += int64(w) + if sum < int64(w) { + panic("weights overflow") + } + } + if sum == 0 { + panic("total weight is zero") + } + r := uint64(mathrand.New(&cryptoSource{}).Int63n(sum)) + for i, w := range weights { + if r < uint64(w) { + return i + } + r -= uint64(w) + } + panic("impossible") +} diff --git a/dnstt-client/weightedlist_test.go b/dnstt-client/weightedlist_test.go new file mode 100644 index 0000000..56dd6fc --- /dev/null +++ b/dnstt-client/weightedlist_test.go @@ -0,0 +1,101 @@ +package main + +import ( + "testing" +) + +func TestParseWeightedList(t *testing.T) { + // Good inputs. + for _, test := range []struct { + input string + expectedWeights []uint32 + expectedLabels []string + }{ + {"a", []uint32{1}, []string{"a"}}, + {"apple", []uint32{1}, []string{"apple"}}, + {"1*apple", []uint32{1}, []string{"apple"}}, + {"apple,2*carrot,1*apple", []uint32{1, 2, 1}, []string{"apple", "carrot", "apple"}}, + {"\\a", []uint32{1}, []string{"a"}}, + {"\\*", []uint32{1}, []string{"*"}}, + {"\\,", []uint32{1}, []string{","}}, + {"3\\*apple\\,car\\rot,100*orange", []uint32{1, 100}, []string{"3*apple,carrot", "orange"}}, + } { + weights, labels, err := parseWeightedList(test.input) + if err != nil { + t.Errorf("%+q resulted in error: %v", test.input, err) + continue + } + i := 0 + for ; i < len(weights) && i < len(labels) && i < len(test.expectedWeights) && i < len(test.expectedLabels); i++ { + if weights[i] != test.expectedWeights[i] { + break + } + if labels[i] != test.expectedLabels[i] { + break + } + } + if i < len(test.expectedWeights) || i < len(test.expectedLabels) { + t.Errorf("%+q: expected %v, %v, got %v, %v", test.input, + test.expectedWeights, test.expectedLabels, weights, labels) + continue + } + } + + // Bad inputs. + for _, input := range []string{ + "", + "apple*1", + ",", + ",apple", + "apple,", + "apple,,carrot", + "*", + "**", + "5*apple*5", + "-5*apple", + "5.5*apple", + } { + _, _, err := parseWeightedList(input) + if err == nil { + t.Errorf("%+q resulted in no error", input) + continue + } + } +} + +func TestSampleWeighted(t *testing.T) { + // Total weight of zero should result in a panic. + for _, weights := range [][]uint32{ + {}, + {0}, + {0, 0, 0, 0, 0}, + } { + func() { + defer func() { + r := recover() + if r == nil { + t.Errorf("%v: expected panic", weights) + } + }() + sampleWeighted(weights) + }() + } + + // If there is only one nonzero weight, it should be always selected. + for _, test := range []struct { + weights []uint32 + index int + }{ + {[]uint32{1}, 0}, + {[]uint32{1, 0, 0, 0, 0}, 0}, + {[]uint32{0, 0, 0, 0, 1}, 4}, + {[]uint32{0, 0, 0xffffffff, 0, 1}, 2}, + } { + for i := 0; i < 100; i++ { + index := sampleWeighted(test.weights) + if index != test.index { + t.Errorf("%v: expected %d, got %d", test.weights, test.index, index) + } + } + } +}