Skip to content

Repository files navigation

khm — known hosts manager

CI Release Pure C Zero Dependencies Platform

A CLI tool for managing SSH known_hosts files. No libssh, no OpenSSL, no nothing — raw BSD sockets, a hand-rolled SHA-256, and a partial SSH handshake to fetch host keys directly.

Why

known_hosts is your database of trusted server identities, yet OpenSSH gives you almost no tooling to inspect, compare, audit, or maintain it. You either trust TOFU blindly, grep through a plain-text file by hand, or disable StrictHostKeyChecking in scripts and give up on verification entirely. khm treats known_hosts as what it actually is — a security asset — not a cache you can delete and rebuild without thinking.

Task OpenSSH khm
List trusted hosts grep
Verify one host ssh + compare manually
Verify all hosts
Compare two known_hosts
Export to CSV/Markdown/HTML
Health check (dupes, weak algos, malformed lines)

Real-world use cases

  • CI/CD gatekhm verify --all before a deploy step, fail the pipeline on drift instead of silently trusting whatever's on the runner.
  • Workstation audit — sweep a fleet of dev laptops with khm doctor to catch stale RSA/DSS entries and duplicate junk that's accumulated over years.
  • Post key-rotation check — after rotating a server's host key, confirm every client's known_hosts picked it up cleanly instead of silently falling back to unchecked TOFU.
  • Migration diffkhm diff old_known_hosts new_known_hosts when moving to a new bastion or jump host, to see exactly what changed.
  • Scheduled drift detectionkhm doctor && khm verify --all on a cron, alerting the moment a host key changes unexpectedly.
  • Onboardingkhm export --format md to drop a readable table of trusted hosts straight into internal docs.

Install

curl -Lo khm https://github.com/casablanque-code/khm/releases/latest/download/khm-linux-amd64
chmod +x khm
sudo mv khm /usr/local/bin/

Or build from source (requires only gcc and make):

git clone https://github.com/casablanque-code/khm
cd khm && make
sudo cp khm /usr/local/bin/

Usage

Every command accepts a global --json flag, in any position on the command line (khm --json verify host and khm verify host --json are equivalent). It emits machine-readable JSON instead of formatted text — useful for CI, Ansible, or anything scripted.

Global Options

These flags work across all commands:

  • Custom Port: Append it directly to the host string: khm verify myserver.com:2222
  • Custom File: Pass --file <path> to override the default ~/.ssh/known_hosts.
  • Machine Readable: Pass --json in any position to emit structured JSON instead of text.
  • No Colors: Pass --no-color to disable ANSI terminal styling.
  • Version: khm --version (or -v) prints the exact build (git describe), so you can always tell what's actually installed.

list (alias: ls) — inspect your known_hosts

khm list
khm ls        # same thing
HOST                                      KEY TYPE   KEY (tail)
----------------------------------------  ---------  --------
github.com                                ED25519    ...h2l9GKJl
gitlab.com                                ECDSA-256  ...+Tpockg=
myserver.example.com:2222                 ED25519    ...XwKpZpHs
<hashed>                                  ED25519    ...XwKpZpHs

4 entries  •  /root/.ssh/known_hosts

Key types are color-coded: ED25519 green, ECDSA blue, RSA yellow, hashed entries dimmed.


verify — check a host against known_hosts

khm verify github.com
  host:  github.com
  file:  /root/.ssh/known_hosts
  fetch: ssh-ed25519  SHA256:+DiY3wvvV6TuJJhbpZisF/zLDA0zPMSvHdkr4UvCOqU
  match: ✔ OK  (record #3)

Returns exit code 0 on match, 1 on key change, 3 if not found.

Useful in provisioning scripts:

khm verify myserver.example.com || echo "WARNING: host key mismatch"

verify --all checks every non-hashed host already in the file — a regular drift check, not a one-off:

khm verify --all
  OK           github.com
  OK           gitlab.com
  CHANGED      oldserver.example.com
  UNREACHABLE  decommissioned.example.com

4 hosts  2 OK  1 changed  1 unreachable

Exit code 0 only if nothing changed and everything was reachable — plug it into a cron job or CI step:

khm verify --all || alert "known_hosts drift detected"

⚠️ Note on Hashed Entries: If your OpenSSH has HashKnownHosts yes enabled (the default on many distros), hostnames are cryptographically hashed. khm verify --all will skip these because it's mathematically impossible to recover the IP/domain from the hash to connect to it. To verify hashed entries, use explicit target invocation:

khm verify github.com

fingerprint — check a host's key before you trust it

khm fingerprint github.com
  host:  github.com
  type:  ssh-ed25519
  fp:    SHA256:+DiY3wvvV6TuJJhbpZisF/zLDA0zPMSvHdkr4UvCOqU

Unlike verify, this never touches known_hosts — it's the "what would TOFU show me right now" check, useful before you've ever connected, or when comparing against a fingerprint published out-of-band (e.g. GitHub's SSH key fingerprints page).


lookup — find a host's record, even if it's hashed

khm lookup github.com
  matched  record #12  (hashed, ssh-ed25519 — hostname recovered via salt)
    fingerprint: SHA256:+DiY3wvvV6TuJJhbpZisF/zLDA0zPMSvHdkr4UvCOqU

1 match

HashKnownHosts stores |1|salt|hash| instead of the plaintext hostname, so a hashed known_hosts normally can't be grepped or matched by eye. But the salt is right there in the entry — recompute HMAC-SHA1(salt, candidate-hostname) yourself and compare, and a hashed entry is exactly as searchable as a plaintext one, as long as you already have a hostname to try. This is entirely local and does no network I/O; it's a lookup, not a check.


diff — compare two known_hosts files

khm diff <file1> <file2>
--- /root/.ssh/known_hosts
+++ /backup/known_hosts

~ CHANGED  gitlab.com                        ecdsa-sha2-nistp256
  - ...++Tpockg=
  + ...++NEWKEY=
- REMOVED  oldserver.example.com             ssh-rsa
+ ADDED    newserver.example.com             ssh-ed25519

3 difference(s)

Returns exit code 0 if identical, 1 if differences found.


scan — scan a network or host list

khm scan <cidr|file|host> [--file <known_hosts>] [--port N] [--timeout N] [--concurrency N] [--verbose] [--force] [--no-color]
khm scan 10.0.0.0/24
khm scan hosts.txt --file ~/.ssh/known_hosts
khm scan myserver.example.com
scan  10.0.0.0/24  port 22  254 hosts  concurrency 64

  ✔ OK       10.0.0.1     ssh-ed25519   SHA256:...
  ✔ OK       10.0.0.5     ssh-ed25519   SHA256:...
  ✘ CHANGED  10.0.0.12    ssh-ed25519   SHA256:...
  ? NEW      10.0.0.99    ssh-rsa       SHA256:...

summary  3 ok  1 refused  240 unreachable  6 timeout  4 ssh-error

Hosts are scanned in parallel, --concurrency wide (default 64, capped at 64). Returns exit code 1 if any key has changed.

A CIDR range that reaches outside private address space (RFC 1918, loopback, link-local, or RFC 6598 CGNAT) is refused unless --force is passed — khm scan 8.8.8.0/24 or khm scan 0.0.0.0/0 stop with an error rather than start knocking on strangers' infrastructure by typo. A bare host or a --file list of hosts is never subject to this — the check is CIDR-expansion-specific, since that's the one input shape where "scan the whole internet" is a single fat-fingered /0 away.

A /24 mostly hits addresses with nothing listening, and "nothing listening" isn't one thing — the summary tells them apart:

  • refused — got a TCP RST; the host is up, nothing's on that port
  • unreachable — DNS failure, no route, or the network dropped it outright
  • timeout — connection attempt just never completed in time (a different signal than refused: often a firewall silently dropping packets rather than an empty port)
  • ssh-error — TCP connected fine, but the far end isn't a workable SSH peer (no banner, protocol didn't proceed, or it sent SSH_MSG_DISCONNECT)

By default only timeout hosts are listed individually (worth a second look — could be a filtered SSH host, not just an empty address); pass --verbose to list every refused/unreachable/ssh-error host too instead of folding them into the summary count.


export — get known_hosts data out, for audits

khm export [--file <path>] [--format csv|md|html|json]
khm export --format csv > known_hosts.csv
khm export --format md   # paste straight into a wiki page or PR description

Defaults to CSV. Hashed entries are included with a <hashed> placeholder and no fingerprint (can't be computed without the real hostname). Multiple hostnames sharing a line are joined with ; so the comma stays the CSV delimiter.


normalize — dedupe, merge, sort a known_hosts file

khm normalize [--file <path>] [--write] [--no-backup]
khm normalize --file ~/.ssh/known_hosts          # preview to stdout, file untouched
khm normalize --file ~/.ssh/known_hosts --write  # apply, atomically, with a backup
zulu.example,alpha.example ssh-ed25519 AAAA...
gitlab.com ssh-rsa AAAA...

khm normalize: 6 lines -> 2 lines  (2 exact duplicates removed, 1 merged by shared key)
khm normalize: original backed up to '/home/you/.ssh/known_hosts.khm-backup'

Single-file only, on purpose: it will merge two lines into one when they share the exact same algorithm+key (that's just cosmetic — same key, same trusted identity), and it will drop exact duplicate lines (including duplicate hashed entries, which you can't otherwise spot by eye). It will not merge known_hosts with known_hosts.old or any second file — that kind of merge can silently paper over a real key change, which is exactly what doctor and verify exist to catch instead. @cert-authority/@revoked marker lines are always preserved verbatim, never merged or deduped. --write replaces the file atomically (write to a temp file, then rename), so a crash mid-write can't corrupt your original — and by default it also copies the untouched original to <path>.khm-backup first, since this is a trust database, not a scratch file. Pass --no-backup to skip that. The temp file is opened with O_EXCL|O_NOFOLLOW, refusing to write through a pre-existing file or symlink at that path rather than following it.


doctor — health check for known_hosts

khm doctor [--file <path>] [--check-reachable]
  ✘ file_permissions (1)
  ✓ malformed_line
  ✘ duplicate_entries (1)
  ✘ hashed_duplicate (1)
  ✘ obsolete_algorithm (1)
  ✓ mixed_algorithms
  ✘ wildcard_pattern (1)
  ✘ marker_line (1)

  warn   [file_permissions]     known_hosts is group/world-writable (mode 664) — anyone else with access to this system could plant or alter trusted host keys. Fix: chmod 600 ...
  warn   [duplicate_entries]    line 3 is an exact duplicate of line 2 (github.com)
  warn   [hashed_duplicate]     line 6 has the same hashed host as line 5 but a DIFFERENT key (stale entry after rotation?)
  warn   [obsolete_algorithm]   line 4 uses ssh-dss (DSA, deprecated) for legacy.example
  info   [wildcard_pattern]     line 7 matches a pattern (*.corp.example.com), not a single host — trusts every host that fits it
  info   [marker_line]          line 1 is a @cert-authority marker — khm tracks and preserves it, but does not verify certificates signed by it

6 findings  4 warnings  2 info

Nine checks, eight of them fully offline:

check severity catches
file_permissions warning / info known_hosts is group/world-writable (warning — anyone else on the system could plant or alter trusted keys), world-readable (info — reveals which hosts you connect to), or a symlink (info)
malformed_line error a line missing required fields
duplicate_entries warning exact duplicate plain-text lines
hashed_duplicate warning duplicate hashed entries — you can't eyeball these, the hostname is hidden
obsolete_algorithm warning ssh-rsa / ssh-dss
mixed_algorithms info only one host offering several key types — often intentional, never fails the run
wildcard_pattern info only a */? hostname pattern — trusts a whole class of hosts, not one, under this key
marker_line info only @cert-authority / @revoked lines are present and accounted for — a @cert-authority line also gets a caveat that khm never verifies certificates it signs (no CA chain validation anywhere in this tool)
unreachable_host warning opt-in via --check-reachable, the only check that touches the network

Exit code 1 if there's any error or warning finding; info alone never fails the run. Key-size checks (e.g. flagging small RSA moduli) are planned for a later release — they need a base64/ASN.1 decode step that didn't make it into this one.

@cert-authority/@revoked marker lines are parsed and preserved verbatim throughout — including by khm normalize --write, which used to silently drop them (they weren't stored anywhere at all, just skipped like a comment). doctor's marker_line check is partly there to make that visible: if a marker is on file, you'll see it in the checklist, not just hope it survived the last rewrite.

known_hosts files over 100 MiB, or with more than 200,000 lines, are refused outright rather than parsed — a sanity ceiling well above any real file, there purely so a malformed or hostile input can't turn a routine khm list/verify/doctor into an unbounded memory allocation.

How it works

No authentication. No session. No shell. Just enough of the SSH handshake to obtain the host key.

khm verify and khm scan connect over raw TCP, exchange SSH version banners, send a SSH_MSG_KEXINIT, then a SSH_MSG_KEX_ECDH_INIT. The server responds with SSH_MSG_KEX_ECDH_REPLY, which contains the host public key blob — and the connection is closed immediately after.

The SHA-256 fingerprint is computed from the raw key blob using a self-contained implementation (no libcrypto). This matches the output of ssh-keygen -lf.

Implementation

khm/
├── parser.c / parser.h      known_hosts parser
├── hostkey.c / hostkey.h    TCP + partial SSH handshake, host:port parsing
├── sha256.c / sha256.h      SHA-256, RFC 6234
├── sha1.c / sha1.h          SHA-1 / HMAC-SHA1, only for recovering HashKnownHosts entries
├── json.c / json.h          --json serialization (shared by every command)
└── commands/
    ├── list.c               pretty-print known_hosts
    ├── verify.c             verify single host / verify --all
    ├── fingerprint.c        live key fingerprint, no known_hosts involved
    ├── lookup.c             find a host's record, incl. hashed ones (no network)
    ├── export.c             csv/md/html/json export
    ├── normalize.c          dedupe/merge/sort a known_hosts file
    ├── doctor.c             health check
    ├── diff.c               diff two files
    └── scan.c               parallel network scan

Build

make          # debug/dev binary
make release  # static binary → khm-linux-amd64
make clean

Requires: gcc, make, glibc (or musl for static builds). Nothing else.

License

Apache 2.0

About

CLI for auditing SSH host trust

Topics

Resources

Contributing

Security policy

Stars

16 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages