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).
114 lines
4.4 KiB
Python
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
|