test_homeassistant_tls_3300.py 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178
  1. """The Home Assistant connection test names a certificate problem (#3300).
  2. httpx raises an untrusted certificate as ``ConnectError``, so after moving HA
  3. to https the test said "Could not connect to Home Assistant" for an HA that was
  4. up and answering. These run against real local servers on 127.0.0.1.
  5. """
  6. from __future__ import annotations
  7. import asyncio
  8. import ipaddress
  9. import socket
  10. import ssl
  11. from datetime import datetime, timedelta, timezone
  12. from unittest.mock import patch
  13. import pytest
  14. from cryptography import x509
  15. from cryptography.hazmat.primitives import hashes, serialization
  16. from cryptography.hazmat.primitives.asymmetric import ec
  17. from cryptography.x509.oid import NameOID
  18. from backend.app.services.homeassistant import HomeAssistantService
  19. pytestmark = [pytest.mark.asyncio, pytest.mark.unit]
  20. @pytest.fixture(autouse=True)
  21. def _certifi_trust_store(monkeypatch):
  22. """httpx reads SSL_CERT_FILE / SSL_CERT_DIR before certifi (USE_SYSTEM_TRUST_STORE sets one)."""
  23. monkeypatch.delenv("SSL_CERT_FILE", raising=False)
  24. monkeypatch.delenv("SSL_CERT_DIR", raising=False)
  25. def _cert(subject_cn: str, key, issuer_cn: str, issuer_key, san: x509.GeneralName, ca: bool, expired: bool = False):
  26. now = datetime.now(timezone.utc)
  27. return (
  28. x509.CertificateBuilder()
  29. .subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, subject_cn)]))
  30. .issuer_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, issuer_cn)]))
  31. .public_key(key.public_key())
  32. .serial_number(x509.random_serial_number())
  33. .not_valid_before(now - timedelta(days=60 if expired else 1))
  34. .not_valid_after(now - timedelta(days=1) if expired else now + timedelta(days=30))
  35. .add_extension(x509.BasicConstraints(ca=ca, path_length=None), critical=True)
  36. .add_extension(x509.SubjectAlternativeName([san]), critical=False)
  37. # Python 3.13 verifies strictly, which wants key usage and both key identifiers.
  38. .add_extension(
  39. x509.KeyUsage(
  40. digital_signature=True,
  41. content_commitment=False,
  42. key_encipherment=False,
  43. data_encipherment=False,
  44. key_agreement=False,
  45. key_cert_sign=ca,
  46. crl_sign=ca,
  47. encipher_only=False,
  48. decipher_only=False,
  49. ),
  50. critical=True,
  51. )
  52. .add_extension(x509.SubjectKeyIdentifier.from_public_key(key.public_key()), critical=False)
  53. .add_extension(x509.AuthorityKeyIdentifier.from_issuer_public_key(issuer_key.public_key()), critical=False)
  54. .sign(issuer_key, hashes.SHA256())
  55. )
  56. def _pem(path, cert, key=None):
  57. data = cert.public_bytes(serialization.Encoding.PEM)
  58. if key is not None:
  59. data += key.private_bytes(
  60. serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption()
  61. )
  62. path.write_bytes(data)
  63. return str(path)
  64. async def _https_server(certfile: str):
  65. """An HA stand-in that answers /api/ over TLS with the given certificate."""
  66. ctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
  67. ctx.load_cert_chain(certfile)
  68. async def handle(reader, writer):
  69. try:
  70. await reader.readuntil(b"\r\n\r\n")
  71. body = b'{"message": "API running."}'
  72. writer.write(
  73. b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: %d\r\n\r\n%s" % (len(body), body)
  74. )
  75. await writer.drain()
  76. except (ConnectionError, asyncio.IncompleteReadError, ssl.SSLError):
  77. pass
  78. finally:
  79. writer.close()
  80. return await asyncio.start_server(handle, "127.0.0.1", 0, ssl=ctx)
  81. def _port(server) -> int:
  82. return server.sockets[0].getsockname()[1]
  83. async def test_a_self_signed_certificate_is_named(tmp_path):
  84. key = ec.generate_private_key(ec.SECP256R1())
  85. cert = _cert("homeassistant", key, "homeassistant", key, x509.IPAddress(ipaddress.ip_address("127.0.0.1")), True)
  86. server = await _https_server(_pem(tmp_path / "ha.pem", cert, key))
  87. async with server:
  88. result = await HomeAssistantService().test_connection(f"https://127.0.0.1:{_port(server)}", "token")
  89. assert result["success"] is False
  90. assert result["error"].startswith("Home Assistant's certificate is not trusted (self-signed certificate).")
  91. assert "add it to Bambuddy's trust store" in result["error"]
  92. async def test_a_certificate_for_another_hostname_is_named(tmp_path):
  93. """A trusted certificate reached by IP address, as with Let's Encrypt on a LAN."""
  94. ca_key = ec.generate_private_key(ec.SECP256R1())
  95. ca = _cert("Test CA", ca_key, "Test CA", ca_key, x509.DNSName("ca.test"), True)
  96. leaf_key = ec.generate_private_key(ec.SECP256R1())
  97. leaf = _cert("ha.example.org", leaf_key, "Test CA", ca_key, x509.DNSName("ha.example.org"), False)
  98. server = await _https_server(_pem(tmp_path / "leaf.pem", leaf, leaf_key))
  99. ca_file = _pem(tmp_path / "ca.pem", ca)
  100. async with server:
  101. # httpx builds its trust store from certifi.where().
  102. with patch("certifi.where", return_value=ca_file):
  103. trusted = await HomeAssistantService().test_connection(f"https://127.0.0.1:{_port(server)}", "token")
  104. assert trusted["success"] is False
  105. assert trusted["error"].startswith(
  106. "Home Assistant's certificate is not trusted (IP address mismatch, certificate is not valid for '127.0.0.1')."
  107. )
  108. assert trusted["error"].endswith(
  109. "Enter the hostname the certificate was issued for, not an IP address or another name."
  110. )
  111. async def test_an_expired_certificate_is_named(tmp_path):
  112. ca_key = ec.generate_private_key(ec.SECP256R1())
  113. ca = _cert("Test CA", ca_key, "Test CA", ca_key, x509.DNSName("ca.test"), True)
  114. leaf_key = ec.generate_private_key(ec.SECP256R1())
  115. ip = x509.IPAddress(ipaddress.ip_address("127.0.0.1"))
  116. leaf = _cert("homeassistant", leaf_key, "Test CA", ca_key, ip, False, expired=True)
  117. server = await _https_server(_pem(tmp_path / "leaf.pem", leaf, leaf_key))
  118. ca_file = _pem(tmp_path / "ca.pem", ca)
  119. async with server:
  120. with patch("certifi.where", return_value=ca_file):
  121. result = await HomeAssistantService().test_connection(f"https://127.0.0.1:{_port(server)}", "token")
  122. assert result["error"] == (
  123. "Home Assistant's certificate is not trusted (certificate has expired). "
  124. "Renew the certificate Home Assistant serves."
  125. )
  126. async def test_https_to_a_plain_http_port_is_named():
  127. async def handle(reader, writer):
  128. await reader.read(1024)
  129. writer.write(b"HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\n\r\n")
  130. await writer.drain()
  131. writer.close()
  132. server = await asyncio.start_server(handle, "127.0.0.1", 0)
  133. async with server:
  134. result = await HomeAssistantService().test_connection(f"https://127.0.0.1:{_port(server)}", "token")
  135. assert result["success"] is False
  136. assert result["error"].startswith("The secure connection to Home Assistant failed (")
  137. assert result["error"].endswith("Check that Home Assistant serves https at this address and port.")
  138. async def test_nothing_listening_still_says_could_not_connect():
  139. with socket.socket() as s:
  140. s.bind(("127.0.0.1", 0))
  141. port = s.getsockname()[1]
  142. result = await HomeAssistantService().test_connection(f"https://127.0.0.1:{port}", "token")
  143. assert result == {"success": False, "message": None, "error": "Could not connect to Home Assistant"}