TLS encryption with a self-signed PKI and Python’s asyncio module I want to enable TLS encryption with a custom / self-signed PKI for
a self-contained distributed system built with Python 3’s asyncio.

I’ve read Python’s ssl docs as 
well as several tutorials:


http://www.area536.com/projects/be-your-own-certificate-authority-with-openssl/
http://datacenteroverlords.com/2012/03/01/creating-your-own-ssl-certificate-authority/
http://carlo-hamalainen.net/blog/2013/1/24/python-ssl-socket-echo-test-with-self-signed-certificate
https://blog.cloudflare.com/how-to-build-your-own-public-key-infrastructure/
http://blog.gosquadron.com/use-tls


From all this, I extracted the following steps in order to create a minimal
example for a simple client and server with a self-signed PKI.

It seems to work as expected, but I’m unsure if I missed anything or create
non-obvious bugs which could reduce or nullify the security of the system.

Here is what I did so far:

I'll create one root CA which signs a CSR for each machine.  Each machine will
have its own certificate and key.

Considerations for the root CA:


The key length should be 2048 bits, or better, 4096 bits.
A strong passphrase should be used.
The key should not leave the machine (except if you store it on an SD card
and hide it somewhere save).  Level of paranoia should depend on the security
needs.
The certificate should not use the same FQDN as any production system.


Generate the key and a self-signed certificate:

$ openssl genrsa -aes256 -out ca.key 4096
$ openssl req -new -x509 -nodes -key ca.key -out ca.pem -days 1000


Create a key and a certificate signing request (CSR) on a machine:

$ openssl genrsa -out server.key 4096
$ openssl genrsa -out client.key 4096
$ openssl req -new -key server.key -out server.csr
$ openssl req -new -key client.key -out client.csr


Now you can sign the device key with your CA cert:

$ openssl x509 -CA ca.pem -CAkey ca.key -CAcreateserial -req -in server.csr -out server.pem -days 365
$ openssl x509 -CA ca.pem -CAkey ca.key -CAcreateserial -req -in client.csr -out client.pem -days 365


Minimal Python example.  We need different SSLContexts for the client and
server, because they do some things differently.  Both, client and server, will
use TLS 1.2 with ECDH-AESGCM.  They will verify each other’s certificates
against the root CA cert.  The client also checks the server’s host name.  ECDH
keys are only used once per SSL session and compression is disabled:

import asyncio
import ssl


@asyncio.coroutine
def handle_client(reader, writer):
    data = yield from reader.read(100)
    print(data)
    writer.write(b'cya')
    yield from writer.drain()
    writer.close()


@asyncio.coroutine
def client(addr, ssl_ctx):
    reader, writer = yield from asyncio.open_connection(*addr, ssl=ssl_ctx)
    writer.write(b'ohai')
    data = yield from reader.read(100)
    print(data)
    writer.close()


addr = ('127.0.0.1', 5555)
loop = asyncio.get_event_loop()

# Setup server
server_ctx = ssl.SSLContext(ssl.PROTOCOL_TLSv1_2)
server_ctx.verify_mode = ssl.CERT_REQUIRED
server_ctx.options |= ssl.OP_SINGLE_ECDH_USE
server_ctx.options |= ssl.OP_NO_COMPRESSION
server_ctx.load_cert_chain(certfile='server.pem', keyfile='server.key')
server_ctx.load_verify_locations(cafile='ca.pem')
server_ctx.set_ciphers('ECDH+AESGCM')

coro = asyncio.start_server(handle_client, *addr, ssl=server_ctx)
server = loop.run_until_complete(coro)

# Run client
client_ctx = ssl.SSLContext(ssl.PROTOCOL_TLSv1_2)
client_ctx.verify_mode = ssl.CERT_REQUIRED
client_ctx.check_hostname = True
client_ctx.load_cert_chain(certfile='client.pem', keyfile='client.key')
client_ctx.load_verify_locations(cafile='ca.pem')
client_ctx.set_ciphers('ECDH+AESGCM')

loop.run_until_complete(client(addr, client_ctx))

# Shutdown
server.close()
loop.run_until_complete(server.wait_closed())
loop.close()


The script will output

b'ohai'
b'cya'


as expected and TLS seems to work.  Are there any problems with it or things
that could be done better?

Thanks for your feedback.
Since I stumbled on this checking something else, a few minor points


A CA doesn't sign a CSR; it signs (issues) a cert that has some important data taken from the CSR but much data different.
The Subject names of the CA cert and all server and client certs it issues should be unique, and preferably mnemonic. The CA and client names don't need to be FQDNs, or domainnames at all, although if they are FQDNs that helps make them unique, and usually mnemonic. For a server, either the CommonName part of the Subject or the SubjectAlternativeName extension if used should -- and for many but not all clients must -- match the 'hostname' used by the client(s) to connect to the server. Normally this is the FQDN, but in test and intranet settings it might not; your example shows connecting to the loopback or 'localhost' address 127.0.0.1.
ECDH-AESGCM is not a ciphersuite or even a keyexchange in SSL/TLS; ECDH+AESGM is an OpenSSL shorthand for 8 different cipher suites -- only 2 of which work with an RSA key+cert, namely ECDHE-RSA-AES256-GCM-SHA384 and ECDHE-RSA-AES128-GCM-SHA256.
You don't visibly specify the temporary (ephemeral) ecdh parameters (curve) so you are presumably getting a default from Python; OpenSSL itself doesn't have a default, although 1.0.2 up has a nondefault option to automatically use the client preference.
Since you've chosen everything else pretty strong, you should make sure the ECDHE curve isn't unacceptably weak. I would hope it can be found in the Python source; if not, monitor an actual connection with Wireshark or similar to check.