Files
app/backend/fluksio/flow/connector_tls.py
T
stroblme d482b32970 Do not check a pinned certificate's dates
A pin trusts one exact certificate, so an expired one or an engine clock
that is off no longer locks a device out (X509_V_FLAG_NO_CHECK_TIME).
2026-10-07 10:11:08 +02:00

114 lines
4.4 KiB
Python

"""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