feat: ssh_host_keys.HostKeyCheck, trust on first use (NetOrk/netork#1167)
CI / test (3.10) (push) Successful in 35s
CI / test (3.11) (push) Successful in 41s
CI / test (3.12) (push) Successful in 38s
CI / test (3.13) (push) Successful in 33s
CI / test (3.10) (pull_request) Successful in 29s
CI / test (3.11) (pull_request) Successful in 28s
CI / test (3.12) (pull_request) Successful in 33s
CI / test (3.13) (pull_request) Successful in 40s

Drivers get the host keys to expect in optional_args["ssh_host_keys"] (by port)
and report what they saw through optional_args["ssh_host_key_observer"].
With a known key, any other key is refused before authentication
(SshHostKeyMismatch); without one, the first key is accepted. One
implementation for paramiko (HostKeyCheck.connect) and netmiko
(HostKeyCheck.netmiko + observe). A caller or driver that ignores both keys
keeps working as before.

Version 4.19.0.
This commit is contained in:
christianmanivong committed 2026-10-18 21:23:25 +02:00
1 parent bb2229fd9e
commit 94bf399d34
5 files changed
+611 -1

No files matched your search

+14
View File
@@ -181,6 +181,14 @@ from napalm_device_types.ssh_server import (
SshHardeningResult,
plan_ssh_hardening,
)
from napalm_device_types.ssh_host_keys import (
KNOWN_KEYS_ARG as SSH_HOST_KEYS_ARG,
OBSERVER_ARG as SSH_HOST_KEY_OBSERVER_ARG,
HostKeyCheck,
HostKeyObservation,
SshHostKey,
SshHostKeyMismatch,
)
from napalm_device_types.storage import StorageDriver
from napalm_device_types.switch import SwitchDriver
@@ -288,6 +296,12 @@ __all__ = [
"SshHardeningPlan",
"SshHardeningResult",
"plan_ssh_hardening",
"SSH_HOST_KEYS_ARG",
"SSH_HOST_KEY_OBSERVER_ARG",
"HostKeyCheck",
"HostKeyObservation",
"SshHostKey",
"SshHostKeyMismatch",
"parse_virtual_ips",
"add_lag_interfaces",
"driver_supports_ping",
+300
View File
@@ -0,0 +1,300 @@
# -*- coding: utf-8 -*-
"""SSH host keys: trust on first use, and a changed key refused before any login.
A driver that accepts whatever host key answers at a device's address logs in
there with the device's credentials, whoever that is (NetOrk/netork#1167). The
layer above keeps the store of the keys it trusts; a driver only verifies:
* It receives the keys to expect in ``optional_args["ssh_host_keys"]``
(:data:`KNOWN_KEYS_ARG`), by the port they were seen on:
``{"22": {"key_type": "ssh-ed25519", "key": "<base64>"}}``, the two fields of
a ``known_hosts`` line. A driver takes the one for the port it connects to, so
the caller need not know which port that is (Proxmox: its API on 8006, SSH on
22). With a key, any other key is refused before a password or a private key
leaves the caller. Without one, the first key is accepted: trust on first use.
* It reports every key it sees through ``optional_args["ssh_host_key_observer"]``
(:data:`OBSERVER_ARG`), a callable that receives one dict per connection::
{"host": str, "port": int,
"seen": {"key_type", "key", "fingerprint"},
"expected": {...} | None,
"accepted": bool}
``accepted`` is False only for a refused key; the connection then raises
:class:`SshHostKeyMismatch`. A failing observer is logged and ignored, so the
store being away never stops a connection that verified.
A caller that passes neither keeps working as before (first use, every time),
and a driver that ignores both still connects for a caller that passes them:
old and new versions on either side do not break each other.
:class:`HostKeyCheck` is the one implementation, for a paramiko ``SSHClient``
(:meth:`~HostKeyCheck.connect`) and for netmiko (:meth:`~HostKeyCheck.netmiko`,
then :meth:`~HostKeyCheck.observe`). Like :mod:`napalm_device_types.channel` it
works on the objects paramiko hands it and imports no SSH library itself.
"""
from __future__ import annotations
import base64
import hashlib
import logging
import os
import tempfile
from contextlib import contextmanager
from dataclasses import dataclass
from typing import Any, Dict, Iterator, List, Mapping, Optional, Set
from napalm.base.exceptions import ConnectionException
logger = logging.getLogger(__name__)
#: The ``optional_args`` key that carries the host keys to expect, by port.
KNOWN_KEYS_ARG = "ssh_host_keys"
#: The ``optional_args`` key that carries the callable every seen key goes to.
OBSERVER_ARG = "ssh_host_key_observer"
#: The netmiko arguments this module decides; a caller's own values give way.
_NETMIKO_HOST_KEY_ARGS = ("ssh_strict", "system_host_keys", "alt_host_keys", "alt_key_file")
_DEFAULT_SSH_PORT = 22
@dataclass(frozen=True)
class SshHostKey:
"""A host's public key, as the two fields of a ``known_hosts`` line."""
key_type: str
#: The public key blob, base64.
key: str
@property
def fingerprint(self) -> str:
"""``SHA256:…``, as ``ssh-keygen -l`` and the ssh client print it."""
digest = hashlib.sha256(base64.b64decode(self.key)).digest()
return "SHA256:" + base64.b64encode(digest).decode("ascii").rstrip("=")
def as_dict(self) -> Dict[str, str]:
return {"key_type": self.key_type, "key": self.key, "fingerprint": self.fingerprint}
@classmethod
def from_value(cls, value: Any) -> Optional["SshHostKey"]:
"""The key in *value* (a mapping with ``key_type`` and ``key``), or None for none."""
if not value:
return None
if not isinstance(value, Mapping) or not value.get("key_type") or not value.get("key"):
raise ValueError(f"not an SSH host key: {value!r}")
return cls(str(value["key_type"]), str(value["key"]))
@classmethod
def of(cls, pkey: Any) -> "SshHostKey":
"""The key of a paramiko ``PKey`` (what a transport says the server presented)."""
return cls(pkey.get_name(), pkey.get_base64())
def known_hosts_line(self, host: str, port: int) -> str:
"""The line under which an ssh client looks this host up."""
name = host if port == _DEFAULT_SSH_PORT else f"[{host}]:{port}"
return f"{name} {self.key_type} {self.key}"
@dataclass(frozen=True)
class HostKeyObservation:
"""The key one connection saw, against the key it was to expect."""
host: str
port: int
seen: SshHostKey
expected: Optional[SshHostKey]
@property
def accepted(self) -> bool:
"""True on first use and for the expected key; False for a refused one."""
return self.expected is None or self.expected == self.seen
def as_dict(self) -> Dict[str, Any]:
return {
"host": self.host,
"port": self.port,
"seen": self.seen.as_dict(),
"expected": self.expected.as_dict() if self.expected else None,
"accepted": self.accepted,
}
class SshHostKeyMismatch(ConnectionException): # type: ignore[misc]
"""The device presented another host key than the one it is known by.
Raised before any authentication: no password and no private key went to it.
"""
def __init__(self, observation: HostKeyObservation) -> None:
self.observation = observation
expected = observation.expected
trusted = f"{expected.fingerprint} ({expected.key_type})" if expected else "none"
super().__init__(
f"SSH host key mismatch at {observation.host}:{observation.port}: it presented "
f"{observation.seen.fingerprint} ({observation.seen.key_type}), the trusted key is "
f"{trusted}. Nothing was sent to it."
)
class _AcceptFirst:
"""A paramiko missing-host-key policy for first use: accept, keep nothing.
The key is reported once the connection stands (:meth:`HostKeyCheck.observe`);
nothing is written to a ``known_hosts`` file.
"""
def missing_host_key(self, client: Any, hostname: str, key: Any) -> None:
return None
class _RefuseUnknown:
"""A paramiko policy for a host with a known key: refuse anything else.
paramiko compares a loaded key itself and raises ``BadHostKeyException``
for another one; this only answers if the loaded key was not found under
the name connected to, which must not turn into an accepted key.
"""
def missing_host_key(self, client: Any, hostname: str, key: Any) -> None:
raise ConnectionException(f"SSH host key of {hostname} is not the one expected")
def _expected_key(known: Any, port: int) -> Optional[SshHostKey]:
"""The key in *known* (``{port: key}``, the port as text or number) for *port*."""
if not known:
return None
if not isinstance(known, Mapping):
raise ValueError(f"SSH host keys must be given by port, not as {type(known).__name__}")
return SshHostKey.from_value(known.get(str(port)) or known.get(port))
def _refused_key(exc: BaseException) -> Optional[Any]:
"""paramiko's ``BadHostKeyException`` in *exc*'s chain (netmiko and drivers wrap it)."""
seen: Set[int] = set()
stack: List[Optional[BaseException]] = [exc]
while stack:
current = stack.pop()
if current is None or id(current) in seen:
continue
seen.add(id(current))
if hasattr(current, "expected_key") and hasattr(current, "key"):
return current
stack.extend([current.__cause__, current.__context__])
return None
class HostKeyCheck:
"""How one driver connection verifies its host's key.
*optional_args* are the driver's (both keys optional), *host* and *port* the
address the connection goes to, as given to paramiko or netmiko.
"""
def __init__(self, optional_args: Optional[Mapping[str, Any]], host: str, port: int) -> None:
args = optional_args or {}
self.host = host
self.port = int(port)
self.expected = _expected_key(args.get(KNOWN_KEYS_ARG), self.port)
self._observer = args.get(OBSERVER_ARG)
# ------------------------------------------------------------------
# paramiko
# ------------------------------------------------------------------
def prepare(self, client: Any) -> None:
"""Before ``client.connect()``: the known key, or acceptance of the first one.
The known key goes into the client's host keys, so paramiko asks the
server for that key type first and refuses any other key itself.
"""
if self.expected is None:
client.set_missing_host_key_policy(_AcceptFirst())
return
with self._known_hosts_file() as path:
client.get_host_keys().load(path)
client.set_missing_host_key_policy(_RefuseUnknown())
def connect(self, client: Any, **kwargs: Any) -> HostKeyObservation:
"""``client.connect()`` to this host with *kwargs*, verified and reported."""
self.prepare(client)
with self.refusing():
client.connect(hostname=self.host, port=self.port, **kwargs)
return self.observe(client.get_transport())
# ------------------------------------------------------------------
# netmiko
# ------------------------------------------------------------------
@contextmanager
def netmiko(self, base: Mapping[str, Any]) -> Iterator[Dict[str, Any]]:
"""``ConnectHandler`` keyword arguments: *base* with the host key decided.
Open the connection inside the block and call :meth:`observe` on its
transport after it. A known key goes to netmiko in a ``known_hosts``
file that exists only for the block, with ``ssh_strict``: should the
file not be read, netmiko refuses rather than accepts.
"""
kwargs = {k: v for k, v in base.items() if k not in _NETMIKO_HOST_KEY_ARGS}
kwargs["system_host_keys"] = False
if self.expected is None:
kwargs.update(ssh_strict=False, alt_host_keys=False)
with self.refusing():
yield kwargs
return
with self._known_hosts_file() as path:
kwargs.update(ssh_strict=True, alt_host_keys=True, alt_key_file=path)
with self.refusing():
yield kwargs
# ------------------------------------------------------------------
# both
# ------------------------------------------------------------------
@contextmanager
def refusing(self) -> Iterator[None]:
"""Around a connect: a refused host key becomes :class:`SshHostKeyMismatch`."""
try:
yield
except Exception as exc:
refused = _refused_key(exc)
if refused is None:
raise
expected = self.expected or SshHostKey.of(refused.expected_key)
offered = SshHostKey.of(refused.key)
observation = HostKeyObservation(self.host, self.port, offered, expected)
self._report(observation)
raise SshHostKeyMismatch(observation) from exc
def observe(self, transport: Any) -> HostKeyObservation:
"""After a connect: the key the server presented on *transport*, reported."""
seen = SshHostKey.of(transport.get_remote_server_key())
observation = HostKeyObservation(self.host, self.port, seen, self.expected)
self._report(observation)
return observation
def _report(self, observation: HostKeyObservation) -> None:
if not callable(self._observer):
return
try:
self._observer(observation.as_dict())
except Exception: # the store is the caller's; a connection does not hang on it
logger.warning(
"Could not report the SSH host key of %s:%s", self.host, self.port, exc_info=True
)
@contextmanager
def _known_hosts_file(self) -> Iterator[str]:
"""A private ``known_hosts`` file holding only the expected key."""
assert self.expected is not None
fd, path = tempfile.mkstemp(prefix="known_hosts-")
try:
with os.fdopen(fd, "w") as f:
f.write(self.expected.known_hosts_line(self.host, self.port) + "\n")
yield path
finally:
try:
os.unlink(path)
except OSError:
pass