Validating SSH host keys with Python
SSH host keys are the fingerprints clients use to recognise a server. A mismatch is not a minor warning: it can indicate a changed key, a rebuilt host, or an interception attempt. Writing a Python script that validates SSH host keys against a known_hosts file turns that trust decision into an explicit check, giving operators a dependable answer before automation connects.
The script below takes a hostname, an expected OpenSSH public-key file and a known_hosts path. It delegates host-pattern lookup to ssh-keygen, then compares the decoded key bytes itself. That keeps hashed hostnames and wildcard behaviour aligned with OpenSSH while leaving the validation result in a clear exit status for scripts, CI jobs and configuration-management tasks.
This complements Phil Schwartz’s open-source work rather than replacing it. DenyHosts limits repeated SSH attacks; host-key validation asks a different question: whether the key presented for a server is already trusted. Kodos and Scratchy reflect the same preference for small, inspectable utilities that solve one operational problem clearly.
How known_hosts expresses trust
A known_hosts file normally stores a host pattern, key algorithm and Base64 key per line, optionally with a comment. Prefixes such as @revoked and @cert-authority change the entry’s meaning. An ordinary entry trusts that exact public key for the named host; it does not trust every key offered by the address.
Hostnames may be plain, comma-separated, wildcarded, negated or hashed with an HMAC marker. These conveniences matter because administrators often use an alias rather than the address shown in a cloud console. The validator must resolve the hostname as SSH would, then compare the complete key rather than only its algorithm or a short prefix.
A practical Python validator
The following version keeps parsing delegated to ssh-keygen, which is already present on most Linux and BSD systems, and performs the key comparison in Python. It makes no network connection. The script reads an OpenSSH public key, asks ssh-keygen for matching entries and returns distinct statuses for trusted, mismatched, unknown and revoked keys.
#!/usr/bin/env python3
import argparse
import base64
import binascii
import shlex
import subprocess
import sys
from pathlib import Path
def public_key(path):
fields = shlex.split(path.read_text(encoding="utf-8"))
if len(fields) < 2:
raise ValueError("expected an OpenSSH public key")
return fields[0], base64.b64decode(fields[1], validate=True)
def matching_entries(hostname, known_hosts):
result = subprocess.run(
["ssh-keygen", "-F", hostname, "-f", str(known_hosts)],
capture_output=True,
text=True,
check=False,
)
if result.returncode not in (0, 1):
raise RuntimeError(result.stderr.strip() or "ssh-keygen failed")
for line in result.stdout.splitlines():
fields = shlex.split(line)
if not fields or fields[0].startswith("#"):
continue
marker = ""
if fields[0].startswith("@"):
marker = fields.pop(0)
if len(fields) < 3:
continue
yield marker, fields[1], base64.b64decode(fields[2], validate=True)
def main():
parser = argparse.ArgumentParser(
description="Validate an SSH host key against known_hosts"
)
parser.add_argument("hostname")
parser.add_argument(
"--known-hosts", type=Path, default=Path("~/.ssh/known_hosts")
)
parser.add_argument("--key", type=Path, required=True)
args = parser.parse_args()
try:
known_hosts = args.known_hosts.expanduser()
expected_key = args.key.expanduser()
if not known_hosts.is_file():
raise ValueError(f"known_hosts not found: {known_hosts}")
key_type, key_data = public_key(expected_key)
if key_type.endswith("-cert-v01@openssh.com"):
raise ValueError("certificate keys need trust-path validation")
found = trusted = revoked = False
for marker, entry_type, entry_data in matching_entries(
args.hostname, known_hosts
):
found = True
if (key_type, key_data) != (entry_type, entry_data):
continue
if marker == "@revoked":
revoked = True
elif marker != "@cert-authority":
trusted = True
except (OSError, ValueError, binascii.Error, RuntimeError) as exc:
print(f"error: {exc}", file=sys.stderr)
return 2
if revoked:
print(f"REVOKED: {args.hostname}", file=sys.stderr)
return 5
if trusted:
print(f"TRUSTED: {args.hostname}")
return 0
if found:
print(f"MISMATCH: {args.hostname}", file=sys.stderr)
return 3
print(f"UNKNOWN: {args.hostname}", file=sys.stderr)
return 4
if __name__ == "__main__":
sys.exit(main())
Use an expected public-key file, never a private key. The algorithm and decoded key bytes must both match. The script skips @cert-authority entries because trust in a certificate authority is not trust in one leaf host key; certificate validation needs a separate trust path.
Handling aliases and key rotation
Aliases need special care. If prod-web-01 and its short name point to the same machine, check the hostname used by the connection and confirm the expected key for that alias. A hashed known_hosts entry is handled by ssh-keygen, but it cannot be casually inspected with grep. Compare the lookup with a fingerprint obtained through an independent channel.
During a legitimate rotation, the old key may remain trusted while the new one produces a mismatch. Do not delete an entry because validation failed. Verify the new fingerprint through the hosting provider’s console, an Ansible-managed inventory or another authenticated source. Then add the new key, retain the old entry as @revoked when policy permits, and test before deployment.
Running checks across Australian infrastructure
Australian home labs often place a Raspberry Pi or NAS behind an NBN connection, sometimes using carrier-grade NAT and a forwarded SSH port, while public addresses may change. A dynamic-DNS alias helps, but a reassigned address can carry a different key. Validate the alias and expected key together; a reachable IP proves nothing.
Australian organisations commonly run workloads in AWS Sydney, Azure Australia East or Google Cloud Melbourne, often through a managed service provider. Data-residency rules can keep workloads regional while staff use several bastion aliases, each needing a deliberate known_hosts record. Phil Schwartz’s open-source project notes provide portfolio context for this utility, which sits naturally beside DenyHosts and other Linux tools. In Australian support chats, “all good” may close an informal check, yet automation should rely on the script’s explicit status.
Deployment and maintenance routine
Treat known_hosts and expected public keys as security-sensitive configuration. Keep them owned by the account running the check, writable only by that account, and distributed through the same reviewed path as the host inventory.
Preflight checks
- Restrict
~/.ssh/known_hoststo mode0600; never place it in a shared writable directory. - Store expected
.pubfiles in reviewed configuration or encrypted management tooling. - Confirm changed fingerprints through the provider console or a second authorised reviewer.
- Log the hostname and exit status, never a private key.
After deployment, use exit codes as stable interfaces. Ansible, cron, CI and monitoring wrappers can distinguish a local error from an untrusted or replaced server key without parsing prose.
Routine commands
python3 validate_host_key.py web-01 --known-hosts ~/.ssh/known_hosts --key expected/web-01.pubchecks a host.- Exit
0means the exact key is trusted; exit3means the host was found but the key differed. - Exit
4means no host record was found; exit5identifies a revoked key.
Used this way, the validator gives Australian administrators a compact trust check that fits existing SSH tooling and turns a surprising connection warning into controlled, repeatable automation.
