Skip to content

Commit

Permalink
Add ESNI client and server support
Browse files Browse the repository at this point in the history
Implements https://tools.ietf.org/html/draft-ietf-tls-esni-01
Extends the tls.Config API with a ClientESNIKeys structure which must
contain a valid key. If this key is not valid, the handshake will fail.
A GetServerESNIKeys API is also added which allows the server to
dynamically query for an appropriate ESNI key.

Add a new 'esnitool' utility to generate ESNIKeys for testing purposes,
this uses a short lifetime, a single curve and cipher suite. The test
client and server can now be used with these keys. Additionally the test
client can securely query the ESNI key from DNS (hardcoded to use
1.1.1.1:853 using DoT for now).
  • Loading branch information
Lekensteyn committed May 3, 2019
1 parent 7926b4c commit 0b73626
Show file tree
Hide file tree
Showing 16 changed files with 982 additions and 15 deletions.
6 changes: 6 additions & 0 deletions 13.go
Original file line number Diff line number Diff line change
Expand Up @@ -900,6 +900,12 @@ func (hs *clientHandshakeState) processEncryptedExtensions(ee *encryptedExtensio
c.clientProtocol = ee.alpnProtocol
c.clientProtocolFallback = false
}
if len(hs.esniNonce) != 0 {
// ESNI was requested, it must be present with a valid nonce.
if subtle.ConstantTimeCompare(ee.esniNonce, hs.esniNonce) != 1 {
return c.sendAlert(alertIllegalParameter)
}
}
return nil
}

Expand Down
1 change: 1 addition & 0 deletions _dev/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@
/tris-testclient/tris-testclient
/caddy/caddy
/caddy/echo
/esnitool/esnitool
8 changes: 8 additions & 0 deletions _dev/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -118,11 +118,13 @@ build-all: \
build-test-tris-client \
build-test-tris-server \
build-test-bogo \
build-esnitool \
$(addprefix build-test-,$(TARGET_TEST_COMPAT))

# Builds TRIS client
build-test-tris-client: $(BUILD_DIR)/$(OS_ARCH)/.ok_$(VER_OS_ARCH)
cd $(DEV_DIR)/tris-testclient && \
GOROOT="$(GOROOT_LOCAL)" $(GO) get -d -v && \
GOROOT="$(GOROOT_LOCAL)" GOOS=linux CGO_ENABLED=0 $(GO) build -v -i .
$(DOCKER) build -t tris-testclient $(DEV_DIR)/tris-testclient

Expand All @@ -132,6 +134,12 @@ build-test-tris-server: $(BUILD_DIR)/$(OS_ARCH)/.ok_$(VER_OS_ARCH)
GOROOT="$(GOROOT_LOCAL)" GOOS=linux CGO_ENABLED=0 $(GO) build -v -i .
$(DOCKER) build -t tris-localserver $(DEV_DIR)/tris-localserver

# Builds esnitool for ESNI tests
build-esnitool: $(BUILD_DIR)/$(OS_ARCH)/.ok_$(VER_OS_ARCH)
cd $(DEV_DIR)/esnitool && \
GOROOT="$(GOROOT_LOCAL)" $(GO) get -d -v && \
GOROOT="$(GOROOT_LOCAL)" $(GO) build -v -i .

# BoringSSL specific stuff
build-test-boring: BUILDARG=--build-arg REVISION=$(BORINGSSL_REVISION)

Expand Down
137 changes: 137 additions & 0 deletions _dev/esnitool/esnitool.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
// Standalone utility to generate ESNI keys.
// Can be run independently of tris.
package main

import (
"crypto/rand"
"crypto/sha256"
"crypto/tls"
"encoding/base64"
"flag"
"fmt"
"io/ioutil"
"log"
"time"

"golang.org/x/crypto/cryptobyte"
"golang.org/x/crypto/curve25519"
)

// Internal definitions, copied from common.go and esni.go

type keyShare struct {
group tls.CurveID
data []byte
}

const esniKeysVersionDraft01 uint16 = 0xff01

func addUint64(b *cryptobyte.Builder, v uint64) {
b.AddUint32(uint32(v >> 32))
b.AddUint32(uint32(v))
}

// ESNIKeys structure that is exposed through DNS.
type ESNIKeys struct {
version uint16
checksum [4]uint8
// (Draft -03 introduces "public_name" here)
keys []keyShare // 16-bit vector length
cipherSuites []uint16 // 16-bit vector length
paddedLength uint16
notBefore uint64
notAfter uint64
extensions []byte // 16-bit vector length. No extensions are defined in draft -01
}

func (k *ESNIKeys) serialize() []byte {
var b cryptobyte.Builder
b.AddUint16(k.version)
b.AddBytes(k.checksum[:])
b.AddUint16LengthPrefixed(func(b *cryptobyte.Builder) {
for _, ks := range k.keys {
b.AddUint16(uint16(ks.group))
b.AddUint16LengthPrefixed(func(b *cryptobyte.Builder) {
b.AddBytes(ks.data)
})
}
})
b.AddUint16LengthPrefixed(func(b *cryptobyte.Builder) {
for _, cs := range k.cipherSuites {
b.AddUint16(cs)
}
})
b.AddUint16(k.paddedLength)
addUint64(&b, k.notBefore)
addUint64(&b, k.notAfter)
// No extensions are defined in the initial draft.
b.AddUint16(0)
// Should always succeed as we use simple types only.
return b.BytesOrPanic()
}

func generateX25519() ([]byte, keyShare) {
var scalar, public [32]byte
if _, err := rand.Read(scalar[:]); err != nil {
panic(err)
}
curve25519.ScalarBaseMult(&public, &scalar)
ks := keyShare{
group: tls.X25519,
data: public[:],
}
return scalar[:], ks
}

// Creates a new ESNIKeys structure with a new semi-static key share.
// Returns the private key and a new ESNIKeys structure.
func NewESNIKeys(validity time.Duration) ([]byte, *ESNIKeys) {
serverPrivate, serverKS := generateX25519()
notBefore := time.Now()
notAfter := notBefore.Add(validity)
k := &ESNIKeys{
version: esniKeysVersionDraft01,
keys: []keyShare{serverKS},
cipherSuites: []uint16{tls.TLS_AES_128_GCM_SHA256},
// draft-ietf-tls-esni-01: "If the server supports wildcard names, it SHOULD set this value to 260."
paddedLength: 260,
notBefore: uint64(notBefore.Unix()),
notAfter: uint64(notAfter.Unix()),
}
data := k.serialize()
hash := sha256.New()
hash.Write(data[:2]) // version
hash.Write([]byte{0, 0, 0, 0})
hash.Write(data[6:]) // fields after checksum
copy(k.checksum[:], hash.Sum(nil)[:4])
return serverPrivate, k
}

func main() {
var esniKeysFile, esniPrivateFile string
var validity time.Duration
flag.StringVar(&esniKeysFile, "esni-keys-file", "", "Write base64-encoded ESNI keys to file instead of stdout")
flag.StringVar(&esniPrivateFile, "esni-private-file", "", "Write ESNI private key to file instead of stdout")
flag.DurationVar(&validity, "validity", 24*time.Hour, "Validity period of the keys")
flag.Parse()

serverPrivate, k := NewESNIKeys(validity)
esniBase64 := base64.StdEncoding.EncodeToString(k.serialize())
if esniKeysFile == "" {
// draft -01 uses a TXT record instead of a dedicated RR.
fmt.Printf("_esni TXT record: %s\n", esniBase64)
} else {
err := ioutil.WriteFile(esniKeysFile, []byte(esniBase64+"\n"), 0644)
if err != nil {
log.Fatalf("Failed to write %s: %s", esniKeysFile, err)
}
}
if esniPrivateFile == "" {
fmt.Printf("ESNI private key: %x\n", serverPrivate)
} else {
err := ioutil.WriteFile(esniPrivateFile, serverPrivate, 0600)
if err != nil {
log.Fatalf("Failed to write %s: %s", esniPrivateFile, err)
}
}
}
47 changes: 41 additions & 6 deletions _dev/interop_test_runner.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
#!/usr/bin/env python3

import docker
import unittest
import os
import re
import subprocess
import sys
import tempfile
import time
import unittest

# Regex patterns used for testing

Expand Down Expand Up @@ -34,17 +38,17 @@ def get_ip(self, server):
tris_localserver_container = self.d.containers.get(server)
return tris_localserver_container.attrs['NetworkSettings']['IPAddress']

def run_client(self, image_name, cmd):
def run_client(self, image_name, cmd, volumes=None):
''' Runs client and returns tuple (status_code, logs) '''
c = self.d.containers.run(image=image_name, detach=True, command=cmd)
c = self.d.containers.run(image=image_name, detach=True, command=cmd, volumes=volumes)
res = c.wait()
ret = c.logs().decode('utf8')
c.remove()
return (res['StatusCode'], ret)

def run_server(self, image_name, cmd=None, ports=None, entrypoint=None):
def run_server(self, image_name, cmd=None, ports=None, entrypoint=None, volumes=None):
''' Starts server and returns docker container '''
c = self.d.containers.run(image=image_name, auto_remove=True, detach=True, command=cmd, ports=ports, entrypoint=entrypoint)
c = self.d.containers.run(image=image_name, auto_remove=True, detach=True, command=cmd, ports=ports, entrypoint=entrypoint, volumes=volumes)
# TODO: maybe can be done better?
time.sleep(3)
return c
Expand Down Expand Up @@ -97,15 +101,41 @@ class InteropServer(object):
def setUpClass(self):
self.d = Docker()
try:
self.server = self.d.run_server(self.TRIS_SERVER_NAME)
self.tmpdir = self._create_tmpdir()
self._create_esni_keys()
self.server = self.d.run_server(self.TRIS_SERVER_NAME, volumes=self.data_volumes())
except:
if hasattr(self, 'tmpdir'):
self.tmpdir.cleanup()
self.d.close()
raise

@classmethod
def tearDownClass(self):
self.server.kill()
self.d.close()
self.tmpdir.cleanup()

@classmethod
def _create_tmpdir(cls):
basedir = None
if sys.platform == 'darwin':
# Workaround for TMPDIR=/var/folders/.../T/ which is not permitted
# by the default Docker configuration.
basedir = '/tmp'
return tempfile.TemporaryDirectory(dir=basedir)

@classmethod
def _create_esni_keys(cls):
# Create fresh ESNIKeys that has not expired yet.
esni_priv = os.path.join(cls.tmpdir.name, 'esni.key')
esni_pub = os.path.join(cls.tmpdir.name, 'esni.pub')
esnitool = os.path.join(os.path.dirname(__file__), 'esnitool', 'esnitool')
subprocess.check_call([esnitool, '-esni-keys-file', esni_pub, '-esni-private-file', esni_priv])

@classmethod
def data_volumes(cls):
return {cls.tmpdir.name: {'bind': '/testdata', 'mode': 'ro'}}

@property
def server_ip(self):
Expand Down Expand Up @@ -313,5 +343,10 @@ def test_server_doesnt_support_SIDH(self):
res = self.d.run_client(self.CLIENT_NAME, '-rsa=false -ecdsa=true '+self.server_ip+":7443")
self.assertEqual(res[0], 0)

def test_esni(self):
res = self.d.run_client(self.CLIENT_NAME, '-rsa=false -ecdsa=false -esni-keys=/testdata/esni.pub '+self.server_ip+':1443',
volumes=self.data_volumes())
self.assertEqual(res[0], 0)

if __name__ == '__main__':
unittest.main()
7 changes: 6 additions & 1 deletion _dev/tris-localserver/runner.sh
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
#!/bin/sh

./tris-localserver -b 0.0.0.0:1443 -cert=rsa -rtt0=n 2>&1 & # first port: ECDSA (and no 0-RTT)
esni_args=
if [ -e /testdata/esni.key ]; then
esni_args='-esni-keys=/testdata/esni.pub -esni-private=/testdata/esni.key'
fi

./tris-localserver -b 0.0.0.0:1443 -cert=rsa -rtt0=n $esni_args 2>&1 & # first port: ECDSA (and no 0-RTT)
./tris-localserver -b 0.0.0.0:2443 -cert=ecdsa -rtt0=a 2>&1 & # second port: RSA (and accept 0-RTT but not offer it)
./tris-localserver -b 0.0.0.0:3443 -cert=ecdsa -rtt0=o 2>&1 & # third port: offer and reject 0-RTT
./tris-localserver -b 0.0.0.0:4443 -cert=ecdsa -rtt0=oa 2>&1 & # fourth port: offer and accept 0-RTT
Expand Down
32 changes: 32 additions & 0 deletions _dev/tris-localserver/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package main
import (
"crypto/tls"
"crypto/x509"
"encoding/base64"
"encoding/hex"
"errors"
"flag"
Expand Down Expand Up @@ -154,6 +155,8 @@ func main() {
arg_confirm := flag.Bool("rtt0ack", false, "0-RTT confirm")
arg_clientauth := flag.Bool("cliauth", false, "Performs client authentication (RequireAndVerifyClientCert used)")
arg_pq := flag.String("pq", "", "Enable quantum-resistant algorithms [c: Support classical and Quantum-Resistant, q: Enable Quantum-Resistant only]")
arg_esniKeys := flag.String("esni-keys", "", "File with base64-encoded ESNIKeys")
arg_esniPrivate := flag.String("esni-private", "", "Private key file for ESNI")
flag.Parse()

s.Address = *arg_addr
Expand All @@ -178,6 +181,35 @@ func main() {
enablePQ(s, false)
}

var err error
var esniKeys *tls.ESNIKeys
var esniPrivateKey []byte
if *arg_esniPrivate == "" && *arg_esniKeys != "" ||
*arg_esniPrivate != "" && *arg_esniKeys == "" {
log.Fatal("Both -esni-keys and -esni-private must be provided.")
}
if *arg_esniPrivate != "" {
esniPrivateKey, err = ioutil.ReadFile(*arg_esniPrivate)
if err != nil {
log.Fatalf("Failed to read ESNI private key: %s", err)
}
}
if *arg_esniKeys != "" {
contents, err := ioutil.ReadFile(*arg_esniKeys)
if err != nil {
log.Fatalf("Failed to read ESNIKeys: %s", err)
}
esniKeysBytes, err := base64.StdEncoding.DecodeString(string(contents))
if err != nil {
log.Fatal("Bad -esni-keys: %s", err)
}
esniKeys, err = tls.ParseESNIKeys(esniKeysBytes)
if esniKeys == nil {
log.Fatalf("Cannot parse ESNIKeys: %s", err)
}
s.TLS.GetServerESNIKeys = func([]byte) (*tls.ESNIKeys, []byte, error) { return esniKeys, esniPrivateKey, nil }
}

http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
tlsConn := r.Context().Value(http.TLSConnContextKey).(*tls.Conn)

Expand Down
Loading

0 comments on commit 0b73626

Please sign in to comment.