All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
- Fingerprint output used the URL-safe base64 alphabet (
-/_) instead of the standard one (+//) that OpenSSH's own SHA256 fingerprint format actually uses — the formatssh-keygen -lprints and that hosts publish in their docs. Purely cosmetic forfingerprint/verify's own output and invisible toverify's internal known_hosts matching (which compares raw key bytes, never the fingerprint string), but a real correctness bug forcheck-fingerprint: any externally-sourced fingerprint containing a byte that encodes to+or/could never match, however correct the actual key was. Found via the GitHub Action's first live smoke-test run against github.com. If you've been comparing khm's fingerprint output againstssh-keygen/a host's published fingerprint by eye, re-check now that the alphabet matches.
khm check-fingerprint <host[:port]> --expect <fingerprint>— compares a host's live SSH key against an explicit, caller-supplied fingerprint instead ofknown_hosts. Unlikefingerprint, which just shows you the live key, this checks it against a value you already trust (a secret, a provisioning system's output, something published out-of-band) — built for ephemeral environments like CI runners, where there's no persistentknown_hoststo compare against and no first connection worth trusting on faith before a deploy.--expectaccepts the fingerprint with or without theSHA256:prefix.--jsonsupported, with its own result shape (khm_fp_check_result_t) rather than reusingverify's, since there's no known_hosts record involved.- New exit-code contract for
check-fingerprint, deliberately narrower thanverify's:0match,1mismatch (security failure — host identity changed),2error (timeout/refused/unreachable/non-SSH peer — an infrastructure failure, not necessarily an identity one). Kept as two separate codes rather than one "anything went wrong" code so a CI gate (or a future GitHub Action wrapping this) can branch on which happened.
doctor: newfile_permissionscheck — warns ifknown_hostsis group/world-writable (anyone else on the system could plant or alter trusted keys), notes (info) if it's world-readable or a symlink.doctor:@cert-authoritymarker findings now carry an explicit caveat that khm tracks and preserves the marker but performs no certificate/CA chain verification — full validation would need a much bigger chunk of the SSH protocol than khm's partial handshake implements.scan: CIDR ranges reaching outside private address space (RFC 1918, loopback, link-local, RFC 6598 CGNAT) now require--force. Not a vulnerability in khm — a good-citizenship guard sokhm scan 0.0.0.0/0or a typo'd prefix doesn't start knocking on strangers' infrastructure by accident.known_hostsfiles over 100 MiB, or with more than 200,000 lines, are now refused before parsing rather than read in full — a sanity ceiling well above any real file, there purely to bound memory use against a hostile or malformed input (khm_entry_tis large enough, at ~6KB, that unbounded parsing was a real memory-amplification concern, not a theoretical one).
normalize --write's temp file (<path>.khm-tmp) is now opened withO_CREAT|O_EXCL|O_NOFOLLOW, closing a narrow TOCTOU window where something pre-creating that exact path as a symlink could redirect the write. (known_hostsitself being a symlink was already safe against the finalrename()step — POSIXrename()replaces the destination link rather than following it — this hardens the one step that wasn't already covered by that.)
0.3.0 - 2026-08-10
khm lookup <host>[:port]— finds a host's known_hosts record, includingHashKnownHosts(|1|salt|hash|) entries whose hostname isn't stored in the clear. Recomputes HMAC-SHA1(salt, candidate-hostname) using the entry's own salt and compares against the stored hash — the same checksshditself does on every connection, not a weakness in the scheme. Matches plain-text entries too (case-insensitively, likeverify).--jsonsupported.verify/verify --all: newNEW_ALGORITHMstatus, distinct fromNEW. Previously a host trusted under one algorithm (e.g.ssh-rsaon file) but now offering a different one (e.g.ssh-ed25519) was reported identically to a host never seen before — materially different situations. The--jsonresult gained aknown_algorithmsfield (every algorithm already on file for the host, independent of which one matched); human-readable output now shows known/fetched fingerprints onCHANGEDand known algorithms onNEW_ALGORITHM/OKinstead of a bare verdict. New exit code4forNEW_ALGORITHM(previously folded into3/NEW).normalize --writenow backs up the original file to<path>.khm-backup(preserving permissions) before the atomic replace, unless--no-backupis passed. If the backup itself can't be created, the write is aborted rather than proceeding without a safety net.scan:--concurrency Nreplaces the hardcoded 64-thread batch size (clamped to[1, 64]);--verboselists every individual miss instead of folding non-timeout misses into the summary count.doctor: two new info-severity checks (never fail the run) —wildcard_patternflags a*/?hostname pattern (trusts a whole class of hosts under one key, not a single host, even though it's often intentional), andmarker_linereports each@cert-authority/@revokedline present.
@cert-authority/@revokedmarker lines were silently dropped by the parser — treated exactly like a blank line or comment, never stored anywhere. In practice this meantkhm normalize --write— already the most trust-sensitive command in this tool — deleted every marker line in aknown_hostsfile on a routine rewrite, with no warning, error, or trace. A CA trust anchor or an explicit revocation could vanish without a sign anything had happened. Markers are now parsed into their own record and preserved verbatim (original order) bynormalize, and surfaced bydoctor's newmarker_linecheck.scan: every non-SSH address used to collapse into oneN no-sshcount, whether the port was closed, the host was down, DNS failed, or something answered but wasn't a usable SSH peer. Now classified intorefused/unreachable/timeout/ssh-error, each with a different next action.khm_fetch_hostkey()'s error codes were extended (KHM_FETCH_ERR_REFUSED,KHM_FETCH_ERR_UNREACHABLE,KHM_FETCH_ERR_SSH_ERROR, alongside the existing_GENERIC/_TIMEOUT) rather than replaced — every existingr < 0/r == -2call site kept working unmodified.
- README: documented
lookup, thenormalizebackup guarantee and--no-backup,scan's new failure classes and flags, anddoctor's two new checks. Source tree listing updated to match.
0.2.3 - 2026-07-19
- Write buffer overflow in host key base64 encoding.
keydata_b64inkhm_hostkey_twas sized for the raw key bytes (KHM_KEYDATA_MAX), but base64 expands 3 bytes into 4 characters — a host key blob approaching the existing size cap overflowed the buffer by roughly 700 bytes. Triggerable by a malicious or compromised SSH server responding to any command that fetches a live host key (verify,fingerprint,scan,doctor --check-reachable). More serious than the read-only underflow fixed in 0.2.2. Added a correctly-sizedKHM_KEYDATA_B64_MAXand adst_capbound directly in the base64 encoders as a backstop against this class of bug recurring.
lsas a full alias forlist(same options, same output).-v/--version— prints the build viagit describe, so the binary is self-identifying instead of relying on a manually-bumped string.tests/fuzz/: libFuzzer harnesses —fuzz_kex_reply.ctargets the SSH wire-format parsers (the only attacker-reachable input path in khm) with a seed corpus entry reproducing thekeydata_b64overflow above in under a second against unpatched code;fuzz_parser.ctargets the known_hosts line parser.make fuzz-build/make fuzz-smoke, wired into a separate, non-blocking weekly + manual-dispatch CI workflow.tests/cli_smoke.sh— CLI-level smoke tests, covering thelsalias and--version/-v.
normalize --writenow preserves the original file's permission bits (stat+chmodon the temp file beforerename) instead of silently looseningknown_hostspermissions to whateverfopen()+ umask produced.
0.2.2 - 2026-07-18
- Integer underflow in SSH packet length parsing.
khm_ssh_packet_lengths()didn't validatepad_lenagainstpkt_lenbefore computing the payload length — a malicious or compromised server could sendpkt_len=2, pad_len=255to trigger auint32_tunderflow, producing a payload length far larger than the actual buffer and causing a heap out-of-bounds read the next timeKEX_ECDH_REPLYwas parsed. The degenerate zero-payload case is also now rejected.
- Dependency-free test framework (
tests/test.h) and the first unit tests, coveringparser.c,json.c, and the packet-length validation above (76 tests total). Wired into CI viamake test. - Global options documented in README (custom port,
--file,--json,--no-color).
- Bumped the SSH client banner from
khm_0.1tokhm_0.2(was stale since the initial release). - README: sharpened the project's framing (
known_hostsas a trust database people treat as a disposable cache, not just "SSH gives you no tooling for X"), added an OpenSSH-vs-khm comparison table and a "real-world use cases" section. - License changed (see
LICENSE).
0.2.1 - 2026-07-06
- KEX_ECDH_INIT used an all-zero ephemeral key instead of a random one. Root cause of reports of "connection refused or protocol failure" against modern servers (GitHub's included): an all-zero curve25519 point is one of a handful of known low-order points, and RFC 8731 requires compliant servers to detect and reject exactly these (to prevent small-subgroup confinement issues) — the connection was silently dropped by the server the instant
KEX_ECDH_INITwas sent, with noSSH_MSG_DISCONNECT. Older/more permissive servers had tolerated the placeholder value; a compliant modern one wouldn't. Fixed with a newkhm_rand_bytes()(seeded from time+pid — cryptographic quality isn't needed here, since the exchange is discarded immediately after the host key is read; it only needs to avoid the small set of known-bad points). The same helper now also generates theKEXINITcookie, previously drawn from an unseededrand().
- Opt-in
KHM_DEBUG=1tracing through the SSH handshake — no behavior change unless set. Also surfacesSSH_MSG_DISCONNECTreason + description whenever a server sends one, rather than collapsing it into a generic protocol-failure message.
verify --allanddoctor --check-reachableno longer read as a silent all-clear when every entry in the file is hashed.verify --allnow reports "(N hashed entries skipped — hostname unknown, can't verify without it)";doctorgained a separatereachability_noteinfo finding for the same case (kept distinct fromunreachable_hostso it doesn't flip that row red for something that isn't a failure).
0.2.0 - 2026-07-06
- Global
--jsonflag, recognized anywhere on the command line (not just before the subcommand). Newjson.cmodule with two output shapes: static known_hosts records vs. live-check results. verify --all— checks every non-hashed host in aknown_hostsfile in one pass, deduping(host, port)pairs, with a human-readable summary or a--jsonarray. Exit code0only if nothing changed or was unreachable (CI-friendly).khm fingerprint <host[:port]>— shows a host's live key fingerprint without touchingknown_hostsat all. The pre-TOFU "what would I be trusting right now" check.khm export [--file <path>] [--format csv|md|html|json]— CSV (default), Markdown, HTML, or JSON export for audits. Multiple hostnames on one line are joined with;in CSV so the comma stays the delimiter.khm normalize [--file <path>] [--write]— dedupes exact-duplicate lines and merges plain-text entries that share the same algorithm+key across different hostnames. Single-file only, by design: no merging acrossknown_hosts/.old/.bak, since a cross-file merge can silently hide a real key change — that's whatdoctor/verifyare for, notnormalize. Default is a stdout preview;--writedoes an atomic replace (temp file +rename()).khm doctor [--check-reachable]— health check for aknown_hostsfile, offline by default. Six checks:malformed_line(error),duplicate_entries/hashed_duplicate/obsolete_algorithm(warnings),mixed_algorithms(info only),unreachable_host(warning, opt-in via--check-reachable, the only check touching the network).--jsonemits an array of{check, severity, message}.khm_entry_tgainedline_number;khm_db_tgained anerrorslist capturing lines that looked like they should be a record but didn't parse, instead of silently discarding them the way comments/blank lines are.
release.ymlhad drifted from the Makefile's source list and was missing several files added this release — would have failed the release build outright. Switched tomake release CC=musl-gcc ...so the Makefile'sSRCSis the only place this needs to be maintained.
Initial release.
- Core:
known_hostsparser, self-contained SHA-256, and a hand-rolled partial SSH handshake (banner exchange →KEXINIT→KEX_ECDH_INIT/_REPLY) to fetch a live host key without a full SSH session — nolibssh, no OpenSSL. khm list— pretty-printknown_hostsentries.khm verify <host[:port]>— compare a host's live key against what's on file.khm diff <file1> <file2>— compare twoknown_hostsfiles.khm scan <cidr|file|host>— parallel scan (pthread-based) checking every host's live key againstknown_hosts.- CI + release workflow.