From 3bf2be1765f7a33fbce06cbabeb2e2115f2f07c7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 18 Feb 2022 18:08:26 -0600 Subject: [PATCH] Startup with an emergency self signed cert if the ssl certificate cannot be loaded (#66707) --- homeassistant/bootstrap.py | 3 + homeassistant/components/http/__init__.py | 122 ++++++++-- tests/components/http/test_init.py | 272 ++++++++++++++++++++-- 3 files changed, 359 insertions(+), 38 deletions(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index b1b638f844a..986171cbee7 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -158,8 +158,11 @@ async def async_setup_hass( safe_mode = True old_config = hass.config + old_logging = hass.data.get(DATA_LOGGING) hass = core.HomeAssistant() + if old_logging: + hass.data[DATA_LOGGING] = old_logging hass.config.skip_pip = old_config.skip_pip hass.config.internal_url = old_config.internal_url hass.config.external_url = old_config.external_url diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 764138ca5f3..a41329a1548 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -1,22 +1,31 @@ """Support to serve the Home Assistant API as WSGI application.""" from __future__ import annotations +import datetime from ipaddress import IPv4Network, IPv6Network, ip_network import logging import os import ssl +from tempfile import NamedTemporaryFile from typing import Any, Final, Optional, TypedDict, Union, cast from aiohttp import web from aiohttp.typedefs import StrOrURL from aiohttp.web_exceptions import HTTPMovedPermanently, HTTPRedirection +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.x509.oid import NameOID import voluptuous as vol +from yarl import URL from homeassistant.components.network import async_get_source_ip from homeassistant.const import EVENT_HOMEASSISTANT_STOP, SERVER_PORT from homeassistant.core import Event, HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import storage import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.network import NoURLAvailableError, get_url from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass from homeassistant.setup import async_start_setup, async_when_setup_or_start @@ -231,6 +240,7 @@ class HomeAssistantHTTP: self.ssl_profile = ssl_profile self.runner: web.AppRunner | None = None self.site: HomeAssistantTCPSite | None = None + self.context: ssl.SSLContext | None = None async def async_initialize( self, @@ -258,6 +268,11 @@ class HomeAssistantHTTP: setup_cors(self.app, cors_origins) + if self.ssl_certificate: + self.context = await self.hass.async_add_executor_job( + self._create_ssl_context + ) + def register_view(self, view: HomeAssistantView | type[HomeAssistantView]) -> None: """Register a view with the WSGI server. @@ -329,35 +344,100 @@ class HomeAssistantHTTP: self.app.router.add_route("GET", url_path, serve_file) ) - async def start(self) -> None: - """Start the aiohttp server.""" - context: ssl.SSLContext | None - if self.ssl_certificate: + def _create_ssl_context(self) -> ssl.SSLContext | None: + context: ssl.SSLContext | None = None + assert self.ssl_certificate is not None + try: + if self.ssl_profile == SSL_INTERMEDIATE: + context = ssl_util.server_context_intermediate() + else: + context = ssl_util.server_context_modern() + context.load_cert_chain(self.ssl_certificate, self.ssl_key) + except OSError as error: + if not self.hass.config.safe_mode: + raise HomeAssistantError( + f"Could not use SSL certificate from {self.ssl_certificate}: {error}" + ) from error + _LOGGER.error( + "Could not read SSL certificate from %s: %s", + self.ssl_certificate, + error, + ) try: - if self.ssl_profile == SSL_INTERMEDIATE: - context = ssl_util.server_context_intermediate() - else: - context = ssl_util.server_context_modern() - await self.hass.async_add_executor_job( - context.load_cert_chain, self.ssl_certificate, self.ssl_key - ) + context = self._create_emergency_ssl_context() except OSError as error: _LOGGER.error( - "Could not read SSL certificate from %s: %s", - self.ssl_certificate, + "Could not create an emergency self signed ssl certificate: %s", error, ) - return + context = None + else: + _LOGGER.critical( + "Home Assistant is running in safe mode with an emergency self signed ssl certificate because the configured SSL certificate was not usable" + ) + return context - if self.ssl_peer_certificate: - context.verify_mode = ssl.CERT_REQUIRED - await self.hass.async_add_executor_job( - context.load_verify_locations, self.ssl_peer_certificate + if self.ssl_peer_certificate: + if context is None: + raise HomeAssistantError( + "Failed to create ssl context, no fallback available because a peer certificate is required." ) - else: - context = None + context.verify_mode = ssl.CERT_REQUIRED + context.load_verify_locations(self.ssl_peer_certificate) + return context + + def _create_emergency_ssl_context(self) -> ssl.SSLContext: + """Create an emergency ssl certificate so we can still startup.""" + context = ssl_util.server_context_modern() + host: str + try: + host = cast(str, URL(get_url(self.hass, prefer_external=True)).host) + except NoURLAvailableError: + host = "homeassistant.local" + key = rsa.generate_private_key( + public_exponent=65537, + key_size=2048, + ) + subject = issuer = x509.Name( + [ + x509.NameAttribute( + NameOID.ORGANIZATION_NAME, "Home Assistant Emergency Certificate" + ), + x509.NameAttribute(NameOID.COMMON_NAME, host), + ] + ) + cert = ( + x509.CertificateBuilder() + .subject_name(subject) + .issuer_name(issuer) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(datetime.datetime.utcnow()) + .not_valid_after(datetime.datetime.utcnow() + datetime.timedelta(days=30)) + .add_extension( + x509.SubjectAlternativeName([x509.DNSName(host)]), + critical=False, + ) + .sign(key, hashes.SHA256()) + ) + with NamedTemporaryFile() as cert_pem, NamedTemporaryFile() as key_pem: + cert_pem.write(cert.public_bytes(serialization.Encoding.PEM)) + key_pem.write( + key.private_bytes( + serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption(), + ) + ) + cert_pem.flush() + key_pem.flush() + context.load_cert_chain(cert_pem.name, key_pem.name) + return context + + async def start(self) -> None: + """Start the aiohttp server.""" # Aiohttp freezes apps after start so that no changes can be made. # However in Home Assistant components can be discovered after boot. # This will now raise a RunTimeError. @@ -369,7 +449,7 @@ class HomeAssistantHTTP: await self.runner.setup() self.site = HomeAssistantTCPSite( - self.runner, self.server_host, self.server_port, ssl_context=context + self.runner, self.server_host, self.server_port, ssl_context=self.context ) try: await self.site.start() diff --git a/tests/components/http/test_init.py b/tests/components/http/test_init.py index c03c8143bad..79d0a6c4791 100644 --- a/tests/components/http/test_init.py +++ b/tests/components/http/test_init.py @@ -3,11 +3,13 @@ from datetime import timedelta from http import HTTPStatus from ipaddress import ip_network import logging +import pathlib from unittest.mock import Mock, patch import pytest import homeassistant.components.http as http +from homeassistant.helpers.network import NoURLAvailableError from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from homeassistant.util.ssl import server_context_intermediate, server_context_modern @@ -15,6 +17,26 @@ from homeassistant.util.ssl import server_context_intermediate, server_context_m from tests.common import async_fire_time_changed +def _setup_broken_ssl_pem_files(tmpdir): + test_dir = tmpdir.mkdir("test_broken_ssl") + cert_path = pathlib.Path(test_dir) / "cert.pem" + cert_path.write_text("garbage") + key_path = pathlib.Path(test_dir) / "key.pem" + key_path.write_text("garbage") + return cert_path, key_path + + +def _setup_empty_ssl_pem_files(tmpdir): + test_dir = tmpdir.mkdir("test_empty_ssl") + cert_path = pathlib.Path(test_dir) / "cert.pem" + cert_path.write_text("-") + peer_cert_path = pathlib.Path(test_dir) / "peer_cert.pem" + peer_cert_path.write_text("-") + key_path = pathlib.Path(test_dir) / "key.pem" + key_path.write_text("-") + return cert_path, key_path, peer_cert_path + + @pytest.fixture def mock_stack(): """Mock extract stack.""" @@ -118,62 +140,278 @@ async def test_proxy_config_only_trust_proxies(hass): ) -async def test_ssl_profile_defaults_modern(hass): +async def test_ssl_profile_defaults_modern(hass, tmpdir): """Test default ssl profile.""" - assert await async_setup_component(hass, "http", {}) is True - hass.http.ssl_certificate = "bla" + cert_path, key_path, _ = await hass.async_add_executor_job( + _setup_empty_ssl_pem_files, tmpdir + ) with patch("ssl.SSLContext.load_cert_chain"), patch( "homeassistant.util.ssl.server_context_modern", side_effect=server_context_modern, ) as mock_context: + assert ( + await async_setup_component( + hass, + "http", + {"http": {"ssl_certificate": cert_path, "ssl_key": key_path}}, + ) + is True + ) await hass.async_start() await hass.async_block_till_done() assert len(mock_context.mock_calls) == 1 -async def test_ssl_profile_change_intermediate(hass): +async def test_ssl_profile_change_intermediate(hass, tmpdir): """Test setting ssl profile to intermediate.""" - assert ( - await async_setup_component( - hass, "http", {"http": {"ssl_profile": "intermediate"}} - ) - is True - ) - hass.http.ssl_certificate = "bla" + cert_path, key_path, _ = await hass.async_add_executor_job( + _setup_empty_ssl_pem_files, tmpdir + ) with patch("ssl.SSLContext.load_cert_chain"), patch( "homeassistant.util.ssl.server_context_intermediate", side_effect=server_context_intermediate, ) as mock_context: + assert ( + await async_setup_component( + hass, + "http", + { + "http": { + "ssl_profile": "intermediate", + "ssl_certificate": cert_path, + "ssl_key": key_path, + } + }, + ) + is True + ) await hass.async_start() await hass.async_block_till_done() assert len(mock_context.mock_calls) == 1 -async def test_ssl_profile_change_modern(hass): +async def test_ssl_profile_change_modern(hass, tmpdir): """Test setting ssl profile to modern.""" - assert ( - await async_setup_component(hass, "http", {"http": {"ssl_profile": "modern"}}) - is True - ) - hass.http.ssl_certificate = "bla" + cert_path, key_path, _ = await hass.async_add_executor_job( + _setup_empty_ssl_pem_files, tmpdir + ) with patch("ssl.SSLContext.load_cert_chain"), patch( "homeassistant.util.ssl.server_context_modern", side_effect=server_context_modern, ) as mock_context: + assert ( + await async_setup_component( + hass, + "http", + { + "http": { + "ssl_profile": "modern", + "ssl_certificate": cert_path, + "ssl_key": key_path, + } + }, + ) + is True + ) await hass.async_start() await hass.async_block_till_done() assert len(mock_context.mock_calls) == 1 +async def test_peer_cert(hass, tmpdir): + """Test required peer cert.""" + cert_path, key_path, peer_cert_path = await hass.async_add_executor_job( + _setup_empty_ssl_pem_files, tmpdir + ) + + with patch("ssl.SSLContext.load_cert_chain"), patch( + "ssl.SSLContext.load_verify_locations" + ) as mock_load_verify_locations, patch( + "homeassistant.util.ssl.server_context_modern", + side_effect=server_context_modern, + ) as mock_context: + assert ( + await async_setup_component( + hass, + "http", + { + "http": { + "ssl_peer_certificate": peer_cert_path, + "ssl_profile": "modern", + "ssl_certificate": cert_path, + "ssl_key": key_path, + } + }, + ) + is True + ) + await hass.async_start() + await hass.async_block_till_done() + + assert len(mock_context.mock_calls) == 1 + assert len(mock_load_verify_locations.mock_calls) == 1 + + +async def test_emergency_ssl_certificate_when_invalid(hass, tmpdir, caplog): + """Test http can startup with an emergency self signed cert when the current one is broken.""" + + cert_path, key_path = await hass.async_add_executor_job( + _setup_broken_ssl_pem_files, tmpdir + ) + + hass.config.safe_mode = True + assert ( + await async_setup_component( + hass, + "http", + { + "http": {"ssl_certificate": cert_path, "ssl_key": key_path}, + }, + ) + is True + ) + + await hass.async_start() + await hass.async_block_till_done() + assert ( + "Home Assistant is running in safe mode with an emergency self signed ssl certificate because the configured SSL certificate was not usable" + in caplog.text + ) + + assert hass.http.site is not None + + +async def test_emergency_ssl_certificate_not_used_when_not_safe_mode( + hass, tmpdir, caplog +): + """Test an emergency cert is only used in safe mode.""" + + cert_path, key_path = await hass.async_add_executor_job( + _setup_broken_ssl_pem_files, tmpdir + ) + + assert ( + await async_setup_component( + hass, "http", {"http": {"ssl_certificate": cert_path, "ssl_key": key_path}} + ) + is False + ) + + +async def test_emergency_ssl_certificate_when_invalid_get_url_fails( + hass, tmpdir, caplog +): + """Test http falls back to no ssl when an emergency cert cannot be created when the configured one is broken. + + Ensure we can still start of we cannot determine the external url as well. + """ + cert_path, key_path = await hass.async_add_executor_job( + _setup_broken_ssl_pem_files, tmpdir + ) + hass.config.safe_mode = True + + with patch( + "homeassistant.components.http.get_url", side_effect=NoURLAvailableError + ) as mock_get_url: + assert ( + await async_setup_component( + hass, + "http", + { + "http": {"ssl_certificate": cert_path, "ssl_key": key_path}, + }, + ) + is True + ) + await hass.async_start() + await hass.async_block_till_done() + + assert len(mock_get_url.mock_calls) == 1 + assert ( + "Home Assistant is running in safe mode with an emergency self signed ssl certificate because the configured SSL certificate was not usable" + in caplog.text + ) + + assert hass.http.site is not None + + +async def test_invalid_ssl_and_cannot_create_emergency_cert(hass, tmpdir, caplog): + """Test http falls back to no ssl when an emergency cert cannot be created when the configured one is broken.""" + + cert_path, key_path = await hass.async_add_executor_job( + _setup_broken_ssl_pem_files, tmpdir + ) + hass.config.safe_mode = True + + with patch( + "homeassistant.components.http.x509.CertificateBuilder", side_effect=OSError + ) as mock_builder: + assert ( + await async_setup_component( + hass, + "http", + { + "http": {"ssl_certificate": cert_path, "ssl_key": key_path}, + }, + ) + is True + ) + await hass.async_start() + await hass.async_block_till_done() + assert "Could not create an emergency self signed ssl certificate" in caplog.text + assert len(mock_builder.mock_calls) == 1 + + assert hass.http.site is not None + + +async def test_invalid_ssl_and_cannot_create_emergency_cert_with_ssl_peer_cert( + hass, tmpdir, caplog +): + """Test http falls back to no ssl when an emergency cert cannot be created when the configured one is broken. + + When there is a peer cert verification and we cannot create + an emergency cert (probably will never happen since this means + the system is very broken), we do not want to startup http + as it would allow connections that are not verified by the cert. + """ + + cert_path, key_path = await hass.async_add_executor_job( + _setup_broken_ssl_pem_files, tmpdir + ) + hass.config.safe_mode = True + + with patch( + "homeassistant.components.http.x509.CertificateBuilder", side_effect=OSError + ) as mock_builder: + assert ( + await async_setup_component( + hass, + "http", + { + "http": { + "ssl_certificate": cert_path, + "ssl_key": key_path, + "ssl_peer_certificate": cert_path, + }, + }, + ) + is False + ) + await hass.async_start() + await hass.async_block_till_done() + assert "Could not create an emergency self signed ssl certificate" in caplog.text + assert len(mock_builder.mock_calls) == 1 + + async def test_cors_defaults(hass): """Test the CORS default settings.""" with patch("homeassistant.components.http.setup_cors") as mock_setup: