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
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:
1 parent
bb2229fd9e
commit
94bf399d34
5 files changed
+611
-1
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user