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

Vhost support using multiple TLS certificates #2270

Merged
merged 40 commits into from Oct 28, 2021
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
379b1a2
Initial support for using multiple SSL certificates.
Tronic Oct 15, 2021
978a905
Also list IP address subjectAltNames on log.
Tronic Oct 15, 2021
e769733
Use Python 3.7+ way of specifying TLSv1.2 as the minimum version. Lin…
Tronic Oct 15, 2021
13fbaa5
isort
Tronic Oct 15, 2021
794c350
Cleanup, store server name for later use. Add RSA ciphers. Log reject…
Tronic Oct 16, 2021
afb9bd4
Cleanup, linter.
Tronic Oct 16, 2021
643fd54
Alter the order of initial log messages and handling. In particular, …
Tronic Oct 16, 2021
497d58d
Store server name (SNI) to conn_info.
Tronic Oct 16, 2021
69a7e91
Update test with new error message.
Tronic Oct 16, 2021
1bd8c23
Refactor for readability.
Tronic Oct 16, 2021
098b167
Cleanup
Tronic Oct 17, 2021
b82c3f0
Replace old expired test cert with new ones and a script for regenera…
Tronic Oct 17, 2021
01299bf
Refactor TLS tests to a separate file.
Tronic Oct 17, 2021
b5bde3c
Add cryptography to dev deps for rebuilding TLS certs.
Tronic Oct 17, 2021
1760755
Minor adjustment to messages.
Tronic Oct 17, 2021
f59006a
Tests added for new TLS code.
Tronic Oct 17, 2021
da22609
Find the correct log row before testing for message. The order was di…
Tronic Oct 17, 2021
8c9eb42
More log message order fixup. The tests do not account for the logo b…
Tronic Oct 17, 2021
1c3353c
Another attempt at log message indexing fixup.
Tronic Oct 17, 2021
8244f3f
Major TLS refactoring.
Tronic Oct 19, 2021
e3861ab
Remove a problematic logger test that apparently was not adding any c…
Tronic Oct 19, 2021
320361c
Revert accidental commit of uvloop disable.
Tronic Oct 19, 2021
2a8d7e0
Typing fixes / refactoring.
Tronic Oct 19, 2021
32f2f03
Additional test for cert selection. Certs recreated without DNS:local…
Tronic Oct 19, 2021
14f4e8b
Add tests for single certificate path shorthand and SNI information.
Tronic Oct 19, 2021
62a1c36
Move TLS dict processing to CertSimple, make the names field optional…
Tronic Oct 19, 2021
f790e27
Sanic CLI options --tls and --tls-strict-host to use the new features.
Tronic Oct 24, 2021
76f9573
SSL argument typing updated
Tronic Oct 24, 2021
60cfd72
Use ValueError for internal message passing to avoid CertificateError…
Tronic Oct 24, 2021
5d25739
Linter
Tronic Oct 24, 2021
888aee4
Test CLI TLS options.
Tronic Oct 24, 2021
72f6b9d
Merge branch 'main' into tls-vhosts
Tronic Oct 24, 2021
eeea1f0
Merge branch 'main' into tls-vhosts
Tronic Oct 24, 2021
4b28ebb
Merge branch 'main' into tls-vhosts
Tronic Oct 24, 2021
6f7fcfd
Maybe the right codeclimate option now...
Tronic Oct 24, 2021
b7c20b2
Merge branch 'main' into tls-vhosts
ahopkins Oct 27, 2021
ee00e42
Merge remote-tracking branch 'origin/main' into tls-vhosts
Tronic Oct 27, 2021
3991b60
Improved TLS argument help, removed support for combining --cert/--ke…
Tronic Oct 27, 2021
66a07c1
Removed support for strict checking without any certs, black forced f…
Tronic Oct 27, 2021
bb793fa
Update CLI tests for stricter TLS options.
Tronic Oct 27, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
14 changes: 3 additions & 11 deletions sanic/app.py
Expand Up @@ -19,7 +19,7 @@
from inspect import isawaitable
from pathlib import Path
from socket import socket
from ssl import Purpose, SSLContext, create_default_context
from ssl import SSLContext
from traceback import format_exc
from types import SimpleNamespace
from typing import (
Expand All @@ -45,6 +45,7 @@
from sanic_routing.route import Route # type: ignore

from sanic import reloader_helpers
from sanic.tls import process_to_context
from sanic.asgi import ASGIApp
from sanic.base import BaseSanic
from sanic.blueprint_group import BlueprintGroup
Expand Down Expand Up @@ -1258,16 +1259,7 @@ def _helper(
auto_reload=False,
):
"""Helper function used by `run` and `create_server`."""

if isinstance(ssl, dict):
# try common aliaseses
cert = ssl.get("cert") or ssl.get("certificate")
key = ssl.get("key") or ssl.get("keyfile")
if cert is None or key is None:
raise ValueError("SSLContext or certificate and key required.")
context = create_default_context(purpose=Purpose.CLIENT_AUTH)
context.load_cert_chain(cert, keyfile=key)
ssl = context
ssl = process_to_context(ssl)
if self.config.PROXIES_COUNT and self.config.PROXIES_COUNT < 0:
raise ValueError(
"PROXIES_COUNT cannot be negative. "
Expand Down
104 changes: 104 additions & 0 deletions sanic/tls.py
@@ -0,0 +1,104 @@
import os
import ssl

from sanic.log import logger


def process_to_context(context):
Tronic marked this conversation as resolved.
Show resolved Hide resolved
"""Process app.run ssl argument from easy formats to full SSLContext."""
if isinstance(context, dict):
# try common aliaseses
cert = context.get("cert") or context.get("certificate")
key = context.get("key") or context.get("keyfile")
if cert is None or key is None:
raise ValueError("SSLContext or certificate and key required.")
context = create_context()
context.load_cert_chain(cert, keyfile=key)
elif isinstance(context, list):
context = SSLSelector(*context).context
return context


def create_context():
"""Create a context with secure crypto and HTTP/1.1 in protocols."""
context = ssl.create_default_context(purpose=ssl.Purpose.CLIENT_AUTH)
context.options |= ssl.OP_NO_TLSv1
context.options |= ssl.OP_NO_TLSv1_1
context.set_alpn_protocols(["http/1.1"])
context.set_ciphers(
"ECDHE-ECDSA-AES256-GCM-SHA384:"
"ECDHE-ECDSA-CHACHA20-POLY1305:"
"ECDHE-ECDSA-AES128-GCM-SHA256"
)
return context


def load_cert_dir(p):
Tronic marked this conversation as resolved.
Show resolved Hide resolved
if os.path.isfile(p):
raise ValueError(f'Certificate directory expected but {p} is a file.')
pub = os.path.join(p, "fullchain.pem")
pub2 = os.path.join(p, "chain.pem")
key = os.path.join(p, "privkey.pem")
if not os.access(key, os.R_OK):
raise Exception(f"Certificate not found or permission denied {key}")
if not os.access(pub, os.R_OK):
if os.access(pub2, os.R_OK):
pub = pub2
else:
raise Exception(
f"Certificate {pub} (alternatively, chain.pem) cannot be read."
)
try:
ctx = create_context()
ctx.load_cert_chain(pub, keyfile=key)
cert = ssl._ssl._test_decode_cert(pub)
except Exception as e:
raise Exception(f"Error reading {pub}: {e}")
return cert, ctx


class SSLSelector:
def __init__(self, *paths):
"""Automatically select SSL certificate based on the hostname that the
client is trying to access, via SSL SNI. Paths to certificate folders
with privkey.pem and fullchain.pem in them should be provided, and
will be matched in the order given whenever there is a new connection.
"""
self.context = create_context()
self.context.sni_callback = self._callback
self.names = []
self.certs = []
for p in paths:
cert, ctx = load_cert_dir(p)
self.names += [
name for dom, name in cert["subjectAltName"] if dom == "DNS"
]
self.certs.append((cert, ctx))
logger.info(f"Loaded certificates for {', '.join(self.names)}")

def find(self, hostname):
"""Find the first certificate that matches the given hostname.

:raises ssl.CertificateError: No matching certificate found.
:return: A matching ssl.SSLContext object if found."""
if not hostname:
raise ssl.CertificateError(
"The client provided no SNI to match for certificate."
)
for cert, ctx in self.certs:
try:
ssl.match_hostname(cert, hostname)
return ctx
except ssl.CertificateError:
pass
raise ssl.CertificateError(
f"No certificate found matching hostname {hostname!r}"
)

def _callback(self, ssock, hostname, ctx):
try:
ssock.context = self.find(hostname)
except ssl.CertificateError:
# I think this is supposed to raise ERR_SSL_UNRECOGNIZED_NAME_ALERT
# on the client side but I am only getting ERR_CONNECTION_CLOSED.
return ssl.ALERT_DESCRIPTION_UNRECOGNIZED_NAME