Repository: glyph/txsni

Issue:
txsni breaks protocol negotiation.
In Twisted 16.3 we added `IProtocolNegotiationFactory`, which `TLSMemoryBIOProtocol` uses to determine what protocols (if any) should be negotiated via ALPN/NPN. `TLSMemoryBIOProtocol` does this when `_createConnection` fires, potentially setting callbacks and data on the context returned by calling `.getContext()` on the the connection object from `serverConnectionForTLS`.

Unfortunately, txsni potentially swaps out the TLS contexts midway through the TLS handshake. This means that the callbacks and data that `TLSMemoryBIOProtocol` sets are only ever set on the *default* TLS context: the others, potentially swapped in later in the connection process, do not have this data provided to them.

This means that, in practice, txsni and protocol negotiation do not work together.

Discussion:
Lukasa: It's also entirely possible that I'm an idiot and that this code is overly complex. Who knows? There aren't any tests, as well, so that makes me nervous.
codecov-io: ## [Current coverage](https://codecov.io/gh/glyph/txsni/pull/10?src=pr) is 39.41% (diff: 37.97%)


> No coverage report found for **master** at 005403a.

> Powered by [Codecov](https://codecov.io?src=pr). Last update [005403a...939c817](https://codecov.io/gh/glyph/txsni/compare/005403abb6b5e53643dae922b88e385706f46275...939c817dc8e319b99a6a78da13aff3a2b2bf2053?src=pr)
glyph: @Lukasa Looks like codecov disagrees (did it just break)
glyph: Oh okay there are actually no tests.

@Lukasa - This actually looks like a fine direction, but it needs some tweaking:

1. `SNIMap.getContext` is dead code now, and can be removed
2. It should no longer inherit from `ContextFactory`
3. Similarly we should be calling `serverConnectionForTLS` to get the `Connection` rather than instantiating it ourselves; we can retrieve its `context` via `Connection.get_context` after the fact.
4. write an test

Also we should probably check in with @mithrandi before doing a release
mithrandi: txacme + h2 seems to work just fine with this branch:
```
*        issuer: CN=Fake LE Intermediate X1
* ALPN, server accepted to use h2
* Using HTTP2, server supports multi-use
* Connection state changed (HTTP/2 confirmed)
```
Anything else you need from me? :)
glyph: @mithrandi Huh. Good to know!  I am wondering if we need to leave `getContext` in place though; does `txacme` touch it at that level?
mithrandi: Nope; the only "touching" that happens is passing a wrapped host mapping to `SNIMap`.
mithrandi: Basically the only thing `txacme` does with `SNIMap` is `TLSMemoryBIOFactory(contextFactory=SNIMap(...))` so it doesn't care about the implementation details at all.
glyph: Fantastic, then the cleanup shouldn't matter.
mithrandi: Could someone put "fixes #10" in the description of this PR? :)
glyph: @mithrandi I stuffed some words into @Lukasa's mouth on the first comment there, hopefully that will do
Lukasa: Ok, assuming I'm not caught up with family stuff this weekend imma have a swing at @glyph's changes for this PR.
Lukasa: Ok I've done the first two. The third one I can't do (@glyph incorrectly believed that `CertificateOptions` implemented `IOpenSSLServerConnectionCreator`, but it doesn't). The fourth one is coming.
Lukasa: Ok, I added tests.

I'm not really happy about this. There are a lot of things I can't easily test in this manner: for example, TxSNI's fallback to use the DEFAULT.pem file is hard to test with Twisted's endpoints because they don't actually let you omit SNI.

Anyway, these tests include a basic bit of "does this legitimately work at all" stuff, including for protocol negotiation. So that is something.
Lukasa: @glyph, you monster! The tests against Twisted 13.2 blow up because IOpenSSLConnectionCreator doesn't exist. So...why did I add it? ;)
mithrandi: @Lukasa As the one who picked "Twisted 13.2" as the minimum version to test on, I can tell you that the choice was essentially arbitrary; let's just bump the minimum version to whatever we need to support this?
Lukasa: Twisted 14.0, I think, then.
Lukasa: So on the coverage front, the thing we seem to be missing is the ALPN stuff. This isn't a surprise: it turns out ubuntu sucks and doesn't have a recent OpenSSL.
glyph: 14.0 is the minimum acceptable version of Twisted for *anything* in production. Before that we don't even verify certs.
Lukasa: Bump for @glyph.
glyph: @Lukasa If I understand the question for me, it is: Travis-CI isn't covering the NPN stuff in this PR because the base image that Travis is testing with has an OpenSSL that's too old?

My solution to that would be: could you submit a separate PR that just bumps the OpenSSL version, either by building one in the travis config, or by selecting a more recent OS to run on travis (perhaps with their "docker" support, I dunno) and then we can land that first?

Patch:
diff --git a/.travis.yml b/.travis.yml
index 0db1bb1..229d059 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -12,10 +12,10 @@ env:
   - TOXENV=py27-twlatest
   - TOXENV=pypy-twlatest OPENSSL_VERSION=1.0.2h
   - TOXENV=pypy-twlatest
-  - TOXENV=py27-tw132 OPENSSL_VERSION=1.0.2h
-  - TOXENV=py27-tw132
-  - TOXENV=pypy-tw132 OPENSSL_VERSION=1.0.2h
-  - TOXENV=pypy-tw132
+  - TOXENV=py27-tw14 OPENSSL_VERSION=1.0.2h
+  - TOXENV=py27-tw14
+  - TOXENV=pypy-tw14 OPENSSL_VERSION=1.0.2h
+  - TOXENV=pypy-tw14
   - TOXENV=py27-twtrunk OPENSSL_VERSION=1.0.2h
   - TOXENV=py27-twtrunk
   - TOXENV=pypy-twtrunk OPENSSL_VERSION=1.0.2h
diff --git a/setup.py b/setup.py
index d6dd2e2..dd239b4 100644
--- a/setup.py
+++ b/setup.py
@@ -13,10 +13,12 @@ setup(
     description="easy-to-use SNI endpoint for twisted",
     packages=[
         "txsni",
+        "txsni.test",
+        "txsni.test.certs",
         "twisted.plugins",
     ],
     install_requires=[
-        "Twisted[tls]>=13.2",
+        "Twisted[tls]>=14.0",
         "pyOpenSSL>=0.14",
     ],
     version="0.1.6",
diff --git a/tox.ini b/tox.ini
index 0981094..a55d144 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,5 +1,5 @@
 [tox]
-envlist = coverage-clean,{py27,pypy}-{twtrunk,twlatest,tw132},coverage-report
+envlist = coverage-clean,{py27,pypy}-{twtrunk,twlatest,tw14},coverage-report
 
 [testenv:coverage-clean]
 deps = coverage
@@ -19,10 +19,11 @@ whitelist_externals =
 deps =
     twlatest: Twisted[tls]
     twtrunk: https://github.com/twisted/twisted/archive/trunk.zip#egg=Twisted[tls]
-    tw132: Twisted[tls]==13.2
+    tw132: Twisted[tls]==14.0
     coverage
+    cryptography
 commands =
     pip list
     mkdir -p {envtmpdir}
     coverage run --parallel-mode \
-        {envdir}/bin/trial --temp-directory={envtmpdir}/_trial_temp {posargs:txsni}
+        -m twisted.trial --temp-directory={envtmpdir}/_trial_temp {posargs:txsni}
diff --git a/txsni/snimap.py b/txsni/snimap.py
index b436aa7..ea75d23 100644
--- a/txsni/snimap.py
+++ b/txsni/snimap.py
@@ -1,12 +1,125 @@
+import collections
+
+from zope.interface import implementer
+
+from OpenSSL.SSL import Connection
+
+from twisted.internet.interfaces import IOpenSSLServerConnectionCreator
 from twisted.internet.ssl import CertificateOptions, ContextFactory
 
 from txsni.only_noticed_pypi_pem_after_i_wrote_this import (
     certificateOptionsFromPileOfPEM
 )
 
-class SNIMap(ContextFactory, object):
+
+class _NegotiationData(object):
+    """
+    A container for the negotiation data.
+    """
+    __slots__ = [
+        'npnAdvertiseCallback',
+        'npnSelectCallback',
+        'alpnSelectCallback',
+        'alpnProtocols'
+    ]
+
+    def __init__(self):
+        self.npnAdvertiseCallback = None
+        self.npnSelectCallback = None
+        self.alpnSelectCallback = None
+        self.alpnProtocols = None
+
+    def negotiateNPN(self, context):
+        if self.npnAdvertiseCallback is None or self.npnSelectCallback is None:
+            return
+
+        context.set_npn_advertise_callback(self.npnAdvertiseCallback)
+        context.set_npn_select_callback(self.npnSelectCallback)
+
+    def negotiateALPN(self, context):
+        if self.alpnSelectCallback is None or self.alpnProtocols is None:
+            return
+
+        context.set_alpn_select_callback(self.alpnSelectCallback)
+        context.set_alpn_protos(self.alpnProtocols)
+
+
+class _ConnectionProxy(object):
+    """
+    A basic proxy for an OpenSSL Connection object that returns a ContextProxy
+    wrapping the actual OpenSSL Context whenever it's asked for.
+    """
+    def __init__(self, original, factory):
+        self._obj = original
+        self._factory = factory
+
+    def get_context(self):
+        """
+        A basic override of get_context to ensure that the appropriate proxy
+        object is returned.
+        """
+        ctx = self._obj.get_context()
+        return _ContextProxy(ctx, self._factory)
+
+    def __getattr__(self, attr):
+        return getattr(self._obj, attr)
+
+    def __setattr__(self, attr, val):
+        if attr in ('_obj', '_factory'):
+            self.__dict__[attr] = val
+
+        return setattr(self._obj, attr, val)
+
+    def __delattr__(self, attr):
+        return delattr(self._obj, attr)
+
+
+class _ContextProxy(object):
+    """
+    A basic proxy object for the OpenSSL Context object that records the
+    values of the NPN/ALPN callbacks, to ensure that they get set appropriately
+    if a context is swapped out during connection setup.
+    """
+    def __init__(self, original, factory):
+        self._obj = original
+        self._factory = factory
+
+    def set_npn_advertise_callback(self, cb):
+        self._factory._npnAdvertiseCallbackForContext(self._obj, cb)
+        return self._obj.set_npn_advertise_callback(cb)
+
+    def set_npn_select_callback(self, cb):
+        self._factory._npnSelectCallbackForContext(self._obj, cb)
+        return self._obj.set_npn_select_callback(cb)
+
+    def set_alpn_select_callback(self, cb):
+        self._factory._alpnSelectCallbackForContext(self._obj, cb)
+        return self._obj.set_alpn_select_callback(cb)
+
+    def set_alpn_protos(self, protocols):
+        self._factory._alpnProtocolsForContext(self._obj, protocols)
+        return self._obj.set_alpn_protos(protocols)
+
+    def __getattr__(self, attr):
+        return getattr(self._obj, attr)
+
+    def __setattr__(self, attr, val):
+        if attr in ('_obj', '_factory'):
+            self.__dict__[attr] = val
+
+        return setattr(self._obj, attr, val)
+
+    def __delattr__(self, attr):
+        return delattr(self._obj, attr)
+
+
+@implementer(IOpenSSLServerConnectionCreator)
+class SNIMap(object):
     def __init__(self, mapping):
         self.mapping = mapping
+        self._negotiationDataForContext = collections.defaultdict(
+            _NegotiationData
+        )
         try:
             self.context = self.mapping['DEFAULT'].getContext()
         except KeyError:
@@ -15,15 +128,42 @@ class SNIMap(ContextFactory, object):
             self.selectContext
         )
 
-    def getContext(self):
-        return self.context
-
     def selectContext(self, connection):
-        connection.set_context(
-            self.mapping[connection.get_servername()]
-            .getContext()
+        oldContext = connection.get_context()
+        newContext = self.mapping[connection.get_servername()].getContext()
+
+        negotiationData = self._negotiationDataForContext[oldContext]
+        negotiationData.negotiateNPN(newContext)
+        negotiationData.negotiateALPN(newContext)
+
+        connection.set_context(newContext)
+
+    def serverConnectionForTLS(self, protocol):
+        """
+        Construct an OpenSSL server connection.
+
+        @param protocol: The protocol initiating a TLS connection.
+        @type protocol: L{TLSMemoryBIOProtocol}
+
+        @return: a connection
+        @rtype: L{OpenSSL.SSL.Connection}
+        """
+        conn = Connection(self.context, None)
+        return _ConnectionProxy(conn, self)
+
+    def _npnAdvertiseCallbackForContext(self, context, callback):
+        self._negotiationDataForContext[context].npnAdvertiseCallback = (
+            callback
         )
 
+    def _npnSelectCallbackForContext(self, context, callback):
+        self._negotiationDataForContext[context].npnSelectCallback = callback
+
+    def _alpnSelectCallbackForContext(self, context, callback):
+        self._negotiationDataForContext[context].alpnSelectCallback = callback
+
+    def _alpnProtocolsForContext(self, context, protocols):
+        self._negotiationDataForContext[context].alpnProtocols = protocols
 
 
 class HostDirectoryMap(object):


Test patch:
diff --git a/txsni/test/__init__.py b/txsni/test/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/txsni/test/certs/__init__.py b/txsni/test/certs/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/txsni/test/certs/cert_builder.py b/txsni/test/certs/cert_builder.py
new file mode 100644
index 0000000..3e826f7
--- /dev/null
+++ b/txsni/test/certs/cert_builder.py
@@ -0,0 +1,175 @@
+from __future__ import print_function, absolute_import
+
+from cryptography import x509
+from cryptography.hazmat.backends import default_backend
+from cryptography.hazmat.primitives import hashes, serialization
+from cryptography.hazmat.primitives.asymmetric import rsa
+from cryptography.x509.oid import NameOID
+import datetime
+import uuid
+import os
+import tempfile
+
+
+ONE_DAY = datetime.timedelta(1, 0, 0)
+THIRTYISH_YEARS = datetime.timedelta(30 * 365, 0, 0)
+TENISH_YEARS = datetime.timedelta(10 * 365, 0, 0)
+
+
+# Various exportable constants that the tests can (and should!) use.
+CERT_DIR = tempfile.mkdtemp()
+ROOT_CERT_PATH = os.path.join(CERT_DIR, 'root_cert.pem')
+ROOT_KEY_PATH = os.path.join(CERT_DIR, 'root_cert.key')
+DEFAULT_CERT_PATH = os.path.join(CERT_DIR, 'DEFAULT.pem')
+DEFAULT_KEY_PATH = os.path.join(CERT_DIR, 'DEFAULT.key')
+HTTP2BIN_CERT_PATH = os.path.join(CERT_DIR, 'http2bin.org.pem')
+HTTP2BIN_KEY_PATH = os.path.join(CERT_DIR, 'http2bin.org.key')
+
+
+# A list of tuples that controls what certs get built and signed by the root.
+# Each tuple is (hostname, cert_path)
+# We'll probably never need the easy extensibility this provides, but hey, nvm!
+_CERTS = [
+    (u'localhost', DEFAULT_CERT_PATH),
+    (u'http2bin.org', HTTP2BIN_CERT_PATH),
+]
+
+
+def _build_root_cert():
+    """
+    Builds a single root certificate that can be used to sign the others. This
+    root cert is basically pretty legit, except for being totally bonkers.
+    Returns a tuple of (certificate, key) for the CA, which can be used to
+    build the leaves.
+    """
+    if os.path.isfile(ROOT_CERT_PATH) and os.path.isfile(ROOT_KEY_PATH):
+        print("Root already exists, not regenerating.")
+        with open(ROOT_CERT_PATH, 'rb') as f:
+            certificate = x509.load_pem_x509_certificate(
+                f.read(), default_backend()
+            )
+        with open(ROOT_KEY_PATH, 'rb') as f:
+            key = serialization.load_pem_private_key(
+                f.read(), password=None, backend=default_backend()
+            )
+        return certificate, key
+
+    private_key = rsa.generate_private_key(
+        public_exponent=65537,
+        key_size=2048,
+        backend=default_backend()
+    )
+    public_key = private_key.public_key()
+    builder = x509.CertificateBuilder()
+    builder = builder.subject_name(x509.Name([
+        x509.NameAttribute(NameOID.COMMON_NAME, u'txsni signing service'),
+    ]))
+    builder = builder.issuer_name(x509.Name([
+        x509.NameAttribute(NameOID.COMMON_NAME, u'txsni signing service'),
+    ]))
+    builder = builder.not_valid_before(datetime.datetime.today() - ONE_DAY)
+    builder = builder.not_valid_after(
+        datetime.datetime.today() + THIRTYISH_YEARS
+    )
+    builder = builder.serial_number(int(uuid.uuid4()))
+    builder = builder.public_key(public_key)
+
+    # Don't allow intermediates.
+    builder = builder.add_extension(
+        x509.BasicConstraints(ca=True, path_length=0), critical=True,
+    )
+
+    certificate = builder.sign(
+        private_key=private_key, algorithm=hashes.SHA256(),
+        backend=default_backend()
+    )
+
+    # Write it out.
+    with open(ROOT_KEY_PATH, 'wb') as f:
+        f.write(
+            private_key.private_bytes(
+                encoding=serialization.Encoding.PEM,
+                format=serialization.PrivateFormat.TraditionalOpenSSL,
+                encryption_algorithm=serialization.NoEncryption()
+            )
+        )
+
+    with open(ROOT_CERT_PATH, 'wb') as f:
+        f.write(
+            certificate.public_bytes(serialization.Encoding.PEM)
+        )
+
+    print("Built root certificate.")
+
+    return certificate, private_key
+
+
+def _build_single_leaf(hostname, certfile, ca_cert, ca_key):
+    """
+    Builds a single leaf certificate, signed by the CA's private key.
+    """
+    if os.path.isfile(certfile):
+        print("%s already exists, not regenerating" % hostname)
+        return
+
+    private_key = rsa.generate_private_key(
+        public_exponent=65537,
+        key_size=2048,
+        backend=default_backend()
+    )
+    public_key = private_key.public_key()
+    builder = x509.CertificateBuilder()
+    builder = builder.subject_name(x509.Name([
+        x509.NameAttribute(NameOID.COMMON_NAME, hostname),
+    ]))
+    builder = builder.issuer_name(ca_cert.subject)
+    builder = builder.not_valid_before(datetime.datetime.today() - ONE_DAY)
+    builder = builder.not_valid_after(
+        datetime.datetime.today() + TENISH_YEARS
+    )
+    builder = builder.serial_number(int(uuid.uuid4()))
+    builder = builder.public_key(public_key)
+
+    builder = builder.add_extension(
+        x509.BasicConstraints(ca=False, path_length=None), critical=True,
+    )
+    builder = builder.add_extension(
+        x509.SubjectAlternativeName([
+            x509.DNSName(hostname)
+        ]),
+        critical=True,
+    )
+
+    certificate = builder.sign(
+        private_key=ca_key, algorithm=hashes.SHA256(),
+        backend=default_backend()
+    )
+
+    # Write it out.
+    with open(certfile, 'wb') as f:
+        f.write(
+            private_key.private_bytes(
+                encoding=serialization.Encoding.PEM,
+                format=serialization.PrivateFormat.TraditionalOpenSSL,
+                encryption_algorithm=serialization.NoEncryption()
+            )
+        )
+        f.write(
+            certificate.public_bytes(serialization.Encoding.PEM)
+        )
+
+    print("Built certificate for %s" % hostname)
+
+
+def _build_certs():
+    """
+    Builds all certificates.
+    """
+    ca_cert, ca_key = _build_root_cert()
+
+    for hostname, certfile in _CERTS:
+        _build_single_leaf(hostname, certfile, ca_cert, ca_key)
+
+
+if __name__ == '__main__':
+    _build_certs()
diff --git a/txsni/test/test_txsni.py b/txsni/test/test_txsni.py
new file mode 100644
index 0000000..5e84369
--- /dev/null
+++ b/txsni/test/test_txsni.py
@@ -0,0 +1,257 @@
+from __future__ import absolute_import
+
+import os
+
+from txsni.snimap import SNIMap, HostDirectoryMap
+from txsni.tlsendpoint import TLSEndpoint
+
+from OpenSSL.crypto import load_certificate, FILETYPE_PEM
+
+from twisted.internet import protocol, endpoints, reactor, defer, interfaces
+from twisted.internet.ssl import (
+    CertificateOptions, optionsForClientTLS, Certificate
+)
+from twisted.python.filepath import FilePath
+from twisted.trial import unittest
+
+from zope.interface import implementer
+
+from .certs.cert_builder import (
+    ROOT_CERT_PATH, HTTP2BIN_CERT_PATH, _build_certs, CERT_DIR
+)
+
+# We need some temporary certs.
+_build_certs()
+
+with open(ROOT_CERT_PATH, 'rb') as f:
+    PEM_ROOT = Certificate.loadPEM(f.read())
+
+
+def sni_endpoint():
+    """
+    Builds a TxSNI TLSEndpoint populated with the default certificates. These
+    are built from cert_builder.py, and have the following certs in the SNI
+    map:
+
+    - DEFAULT.pem, which contains a SAN for 'localhost'.
+    - http2bin.org.pem, which contains a SAN for 'http2bin.org'
+    """
+    base_endpoint = endpoints.TCP4ServerEndpoint(
+        reactor=reactor,
+        port=0,
+        interface='127.0.0.1',
+    )
+    path = FilePath(CERT_DIR)
+    mapping = SNIMap(HostDirectoryMap(path))
+    wrapper_endpoint = TLSEndpoint(base_endpoint, mapping)
+    return wrapper_endpoint
+
+
+def handshake(client_factory, server_factory, hostname, server_endpoint):
+    """
+    Connect a basic Twisted TLS client endpoint to the provided TxSNI
+    TLSEndpoint. Returns a Deferred that fires when the connection has been
+    established with a tuple of an instance of the client protocol and the
+    listening port.
+    """
+    def connect_client(listening_port):
+        port_number = listening_port.getHost().port
+
+        client = endpoints.TCP4ClientEndpoint(
+            reactor, '127.0.0.1', port_number
+        )
+        options = optionsForClientTLS(
+            hostname=hostname, trustRoot=PEM_ROOT
+        )
+        client = endpoints.wrapClientTLS(options, client)
+        connectDeferred = client.connect(client_factory)
+
+        def aggregate(client_proto):
+            return (client_proto, listening_port)
+
+        connectDeferred.addCallback(aggregate)
+        return connectDeferred
+
+    listenDeferred = server_endpoint.listen(server_factory)
+    listenDeferred.addCallback(connect_client)
+    return listenDeferred
+
+
+class WritingProtocol(protocol.Protocol):
+    """
+    A really basic Twisted protocol that fires a Deferred when the TLS
+    handshake has been completed. It detects this using dataReceived, because
+    we can't rely on IHandshakeListener.
+    """
+    def __init__(self, handshake_deferred):
+        self.handshake_deferred = handshake_deferred
+
+    def dataReceived(self, data):
+        cert = self.transport.getPeerCertificate()
+
+        if not skipNegotiation:
+            proto = self.transport.negotiatedProtocol
+        else:
+            proto = None
+        self.transport.abortConnection()
+        self.handshake_deferred.callback((cert, proto))
+        self.handshake_deferred = None
+
+
+class WritingProtocolFactory(protocol.Factory):
+    protocol = WritingProtocol
+
+    def __init__(self, handshake_deferred):
+        self.handshake_deferred = handshake_deferred
+
+    def buildProtocol(self, addr):
+        p = self.protocol(self.handshake_deferred)
+        p.factory = self
+        return p
+
+
+class WriteBackProtocol(protocol.Protocol):
+    """
+    A really basic Twisted protocol that just writes some data to the
+    connection.
+    """
+    def connectionMade(self):
+        self.transport.write('PING')
+        self.transport.loseConnection()
+
+
+try:
+    @implementer(interfaces.IProtocolNegotiationFactory)
+    class NegotiatingFactory(protocol.Factory):
+        """
+        A Twisted Protocol Factory that implements the protocol negotiation
+        extensions
+        """
+        def acceptableProtocols(self):
+            return [b'h2', b'http/1.1']
+
+    class WritingNegotiatingFactory(WritingProtocolFactory,
+                                    NegotiatingFactory):
+        pass
+
+    skipNegotiation = False
+except AttributeError:
+    skipNegotiation = "IProtocolNegotiationFactory not supported"
+
+
+class TestSNIMap(unittest.TestCase):
+    """
+    Tests of the basic SNIMap logic.
+    """
+    def test_snimap_default(self):
+        """
+        SNIMap preferentially loads the DEFAULT value from the mapping if it's
+        present.
+        """
+        options = CertificateOptions()
+        mapping = {'DEFAULT': options}
+        sni_map = SNIMap(mapping)
+
+        conn = sni_map.serverConnectionForTLS(protocol.Protocol())
+        self.assertIs(conn.get_context()._obj, options.getContext())
+
+    def test_snimap_makes_its_own_defaults(self):
+        """
+        If passed a mapping without a DEFAULT key, SNIMap will make its own
+        default context.
+        """
+        options = CertificateOptions()
+        mapping = {'example.com': options}
+        sni_map = SNIMap(mapping)
+
+        conn = sni_map.serverConnectionForTLS(protocol.Protocol())
+        self.assertIsNot(conn.get_context(), options.getContext())
+        self.assertIsNotNone(conn.get_context())
+
+
+class TestCommunication(unittest.TestCase):
+    """
+    Tests that use the full Twisted logic to validate that txsni works as
+    expected.
+    """
+    def assertCertIs(self, protocol_cert, cert_path):
+        """
+        Assert that ``protocol_cert`` is the same certificate as the one at
+        ``cert_path``.
+        """
+        with open(cert_path, 'rb') as f:
+            target_cert = load_certificate(FILETYPE_PEM, f.read())
+
+        self.assertEqual(
+            protocol_cert.digest('sha256'),
+            target_cert.digest('sha256')
+        )
+
+    def test_specific_certificate(self):
+        """
+        When a hostname TxSNI does know about, in this case 'http2bin.org', is
+        provided, TxSNI returns the specific certificate.
+        """
+        handshake_deferred = defer.Deferred()
+        client_factory = WritingProtocolFactory(handshake_deferred)
+        server_factory = protocol.Factory.forProtocol(WriteBackProtocol)
+
+        endpoint = sni_endpoint()
+        d = handshake(
+            client_factory=client_factory,
+            server_factory=server_factory,
+            hostname=u'http2bin.org',
+            server_endpoint=endpoint,
+        )
+
+        def confirm_cert(args):
+            cert, proto = args
+            self.assertCertIs(cert, HTTP2BIN_CERT_PATH)
+            return d
+
+        def close(args):
+            client, port = args
+            port.stopListening()
+
+        handshake_deferred.addCallback(confirm_cert)
+        handshake_deferred.addCallback(close)
+        return handshake_deferred
+
+
+class TestNegotiationStillWorks(unittest.TestCase):
+    """
+    Tests that TxSNI doesn't break protocol negotiation.
+    """
+    if skipNegotiation:
+        skip = skipNegotiation
+
+    def test_specific_cert_still_negotiates(self):
+        """
+        When TxSNI selects a specific cert, protocol negotiation still works.
+        """
+        handshake_deferred = defer.Deferred()
+        client_factory = WritingNegotiatingFactory(handshake_deferred)
+        server_factory = NegotiatingFactory.forProtocol(
+            WriteBackProtocol
+        )
+
+        endpoint = sni_endpoint()
+        d = handshake(
+            client_factory=client_factory,
+            server_factory=server_factory,
+            hostname=u'http2bin.org',
+            server_endpoint=endpoint,
+        )
+
+        def confirm_cert(args):
+            cert, proto = args
+            self.assertEqual(proto, b'h2')
+            return d
+
+        def close(args):
+            client, port = args
+            port.stopListening()
+
+        handshake_deferred.addCallback(confirm_cert)
+        handshake_deferred.addCallback(close)
+        return handshake_deferred