Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add ESNI client and server support #172

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
14 changes: 12 additions & 2 deletions _dev/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -99,18 +99,28 @@ 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; CGO_ENABLED=0 GOROOT="$(GOROOT_LOCAL)" $(GO) build -v -i .
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

# Builds TRIS server
build-test-tris-server: $(BUILD_DIR)/$(OS_ARCH)/.ok_$(VER_OS_ARCH)
cd $(DEV_DIR)/tris-localserver; CGO_ENABLED=0 GOROOT="$(GOROOT_LOCAL)" $(GO) build -v -i .
cd $(DEV_DIR)/tris-localserver && \
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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might it make sense to have esni.go export them or equivalents?


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