"""Trusting a device by the certificate it presented the first time, and no other. A device on the local network presents a certificate no system trusts — its own, or one signed by a vendor root — naming a serial or a MAC rather than the address it is reached at, so neither the system's CAs nor a hostname check can vouch for it. What can be checked is that it is still the same device. The certificate presented on first contact is kept in the connector's store (:attr:`ConnectorNode.store`), and every connection after that trusts that one certificate and nothing else: a different one is refused in the handshake, before a key or a command is sent. Fetching it on first contact sends a TLS hello and nothing more, so a device that is not the device learns nothing from being asked. *address* is ``host`` or ``host:port`` (443 if left out), as it goes in a URL. """ from __future__ import annotations import hashlib import logging import ssl from functools import lru_cache from typing import TYPE_CHECKING import httpx if TYPE_CHECKING: from fluksio.flow.connector_store import ConnectorStore logger = logging.getLogger(__name__) class CertificateChanged(ConnectionError): """The device presented a certificate other than the one pinned for it.""" def __init__(self, address: str, presented: str, node_type: str) -> None: super().__init__( f"{address} presented a different certificate (sha256 {presented}) from " "the one pinned on first contact, so nothing was sent. If the device " "was replaced or reset, clear the pin under Stored state in this " "node's settings, or DELETE " f"/api/v1/connectors/{node_type}/state/{pin_key(address)}." ) def pin_key(address: str) -> str: """Where the certificate of the device at *address* is kept.""" return f"cert:{address}" def fingerprint(pem: str) -> str: return hashlib.sha256(ssl.PEM_cert_to_DER_cert(pem)).hexdigest() def _presented(address: str, timeout: float) -> str: """The certificate the device at *address* presents, as PEM.""" url = httpx.URL(f"https://{address}") return ssl.get_server_certificate((url.host, url.port or 443), timeout=timeout) def pinned_certificate(store: ConnectorStore, address: str, timeout: float) -> str: """The certificate pinned for *address*, pinning the one it presents if none is. Blocking: it reads the store, and on first contact asks the device. A device that does not complete a TLS hello raises :class:`OSError` and pins nothing. """ pem = store.get(pin_key(address)) if pem is None: pem = _presented(address, timeout) store.set(pin_key(address), pem) logger.info( "pinned the certificate of %s: sha256 %s", address, fingerprint(pem) ) return pem @lru_cache(maxsize=32) def context_for(pem: str) -> ssl.SSLContext: """A context trusting this one certificate, whatever signed it.""" context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) # The certificate names the device, not the address it is reached at. context.check_hostname = False # The pinned certificate is the trust anchor, not a chain up to a root. context.verify_flags |= ssl.VERIFY_X509_PARTIAL_CHAIN # X509_V_FLAG_NO_CHECK_TIME, which `ssl` has no name for. A pin trusts one # exact certificate, so its dates add nothing, and a device's certificate # that expired, or a clock that is off, would otherwise lock it out. context.verify_flags |= 0x200000 context.load_verify_locations(cadata=pem) return context def raise_if_changed( exc: BaseException, pem: str, address: str, timeout: float, node_type: str ) -> None: """Say so when *exc* is the handshake refusing a certificate other than *pem*. Anything else — a device that is down, a pinned certificate that expired — is left for the caller to re-raise as it was. *node_type* names the connector in the error's way to clear the pin. """ cause: BaseException | None = exc while cause is not None and not isinstance(cause, ssl.SSLCertVerificationError): cause = cause.__cause__ or cause.__context__ if cause is None: return try: presented = fingerprint(_presented(address, timeout)) except (OSError, ValueError): return if presented != fingerprint(pem): raise CertificateChanged(address, presented, node_type) from exc