ssh-key-monitor

SSH key discovery and change detection

Your firewall alarms on a knock. Your SSH keys don't.

One added line in authorized_keys is a backdoor that survives reboots, image rebuilds and password rotations. This engine finds every SSH key on your machines, keeps a baseline, and raises a typed event the moment a key appears, disappears, gets swapped, or loosens. You keep the weekend.

  • No agent or daemon
  • Linux, macOS, Windows layouts
  • Rust, no unsafe code
~/.ssh/authorized_keys web-03
command="backup --pull",no-pty ssh-ed25519 AAAAC3NzaC1lZDI1NTE5… ops@bastion
ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQ… deploy@ci
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIO… lars@laptop
ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTY… monitoring@metrics
sk-ssh-ed25519@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5… ana@yubikey
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIC… ubuntu@unknown
SshKeyChange::Added path:options:fingerprint steady state: silent

Baseline of 5 keys. One new line. One event, with the identity that makes it actionable.

AddedA key appears, in authorized_keys, known_hosts, or a key directory.
RemovedA key vanishes. Disappearance is reported, not inferred.
PermissionsChangedA file mode loosens, and the previous mode travels with the event.

The gap

The audit you ran last year is the only one that happened.

For an intruder, authorized_keys is the best deal in the house: one line, no malware, survives reboots and password resets. The strongest door on most servers is the one nobody watches.

Persistence is cheap

A key in authorized_keys survives reboots, reimages and password rotations. An attacker adds one and stops needing the exploit.

Restrictions get dropped

command="…",no-pty is the difference between a backup job and a shell. Remove the prefix and the line still looks valid.

Scanners report, they do not watch

A one-time audit tells you what existed that day. The dangerous change happens the day after.

How you find out

There are two ways to learn a stranger has a key.

Nobody adds a key to be helpful. If it happens anyway, you find out on your terms, or on theirs.

Their terms

Friday, 23:47

The pager goes off for something small. While you are in there, you open authorized_keys and count the lines. One too many. Nobody knows when it appeared.

Now every credential since that unknown date is a question mark, and the only clean answer is a rebuild.

Cost: the weekend, and trust in every deploy since a date you cannot name.

Your terms

Same Friday, 23:47

The diff runs on schedule. One event lands in your channel: Added, the path, the fingerprint, the host. You read it from bed.

You revoke the key, close the hole it came through, and the incident is a sentence in standup instead of a quarter of cleanup.

Cost: one line in an alert channel.

What it catches

Four ways SSH access changes. None of them silent.

Every event carries the path, the key kind, the fingerprint when one exists, and the identity that changed. Nothing to grep out of a log line.

Added

A key appears

New authorized_keys entry, new known_hosts host, new key file. One event per entry, never per file, so five new keys read as five events.

Removed

A key vanishes

Removal is as suspicious as arrival. A missing key is a change in access, not a cleanup detail to be discovered later.

PermissionsChanged

A private key loosens

0600 to 0644 carries the previous mode in the event. A comment-only edit on unchanged material stays silent.

Removed + Added

Same path, new key

Private keys are identified by content hash, public keys by fingerprint. A replacement at the same path is a change, not an edit.

Option prefixes are identity. Dropping command="…" or no-pty from an authorized_keys line emits Removed plus Added, because that is the backdoor move. Hashed known_hosts names stay raw in the identity and decode to a matching capability. Private key material never enters an event, a Debug output, or a log. Algorithm, fingerprint and size are derived without decryption.

How it works

Scan, baseline, diff. Then it stays quiet.

No daemon to babysit. Run it from cron, from CI, or from the collector you already have. The work is the diff, and the diff is cheap.

01

Scan

Point it at the standard SSH locations or your own directories. Linux, macOS and Windows layouts are built in. Symlinks are skipped at listing and at open, and every read is bounded.

02

Baseline

The first scan stores identity ids in a durable JSON baseline: atomic writes, 0600, parent-directory fsync, quarantine on corruption. A bad file never masquerades as a clean slate.

03

Diff

Every later scan emits one event per change: Added, Removed, PermissionsChanged. No change, no event. Steady state is silent by design.

Terminal recording of the persist example: a rogue key is planted in a scratch directory, the next scan reports three Added events naming the same rogue fingerprint (public half, private file, authorized_keys entry), and a follow-up scan reports no changes.
A planted key, caught. Recorded from the shipped persist example against a scratch directory; every line is real output. Looping, no JavaScript. Prefer text? Read the full transcript.

Ships as a library plus the ssh-key-audit CLI with exit codes made for scripts and CI. Part of the collector family, and monitor365 consumes the events directly, degrading unknown future variants to warnings instead of crashes. Fingerprints and key sizes are verified byte-equal to ssh-keygen -lf across every supported format, private key containers included.

From the builder

Every fleet I have touched had SSH keys from people who left years before. The strongest door on the server, opened for everyone who ever knocked politely, checked by no one.

So I built a small watchman for it. It reads the framing of your keys, never the secrets. It stays silent while nothing changes. The moment something does, you get one honest event with the identity that changed.

If that is worth a dollar a host to you, wonderful. If it is not, tell me what is missing. I answer every email.

Lars Artmann

Evidence

Security claims are cheap. These are checked.

Every row maps to a test, a gate script, or a lint that fails the build. Ask for the receipts and you get them.

Fingerprints and key sizes match ssh-keygen -lf byte for byte

pinned by tests against real fixtures, openssh, PKCS#1, SEC1, DSA and PKCS#8 containers included

Private key material never enters a log

mechanically enforced by scripts/redaction-check.sh, a release gate that fails on any forbidden field

The engine never touches the network

no sockets, no telemetry, no phone-home; unreadable paths under-report instead of failing

Unsafe code is forbidden, not discouraged

unsafe_code = "forbid" in the crate lints, alongside deny on unwrap, expect, panic and indexing

Shipped, not promised

Through 0.4.0: swap detection, option-prefix identity, hashed-host matching, quarantined baselines, scan-root surfacing, and a machine-readable audit inventory (--json)

Every release passes a seven-combo local battery

stable and MSRV 1.86, engine-only build, pedantic lints at deny, Windows and aarch64 cross-builds, a semver contract gate against the latest tag; scripts/ci-local.sh

Changelog

What shipped, when.

The full notes live in the repository's CHANGELOG; the highlights are inline here, one row per release, newest first.

0.4.0 — 2026-10-25

ssh-key-audit --json: one versioned, deterministic inventory document — path-sorted keys, a summary, every field always present, key material never — pinned by a golden byte-for-byte test; the scan example and the audit CLI now speak one kind vocabulary

0.3.0 — 2026-09-11

scan-root error surfacing: unreadable roots under-report in the default scan and surface as typed per-root failures in the opt-in detailed read; health report moved to the backend; baseline saves delegate to the shared family store

0.2.0 — 2026-09-11

private key type + fingerprint + size + comment derivation without decryption (verified byte-equal to ssh-keygen -lf); opt-in public key blobs; authorized_keys option prefixes; hashed-host matching; the ssh-key-audit CLI; persisted baselines

Pricing

A dollar a host. That is the whole model.

Tiny per server, steady at scale. No seats, no scan limits, no sales calls below a thousand hosts.

Solo
$0forever
Up to 3 hosts, self-hosted
  • Full scan engine and CLI
  • Baseline persistence
  • All scan backends and filters
  • Email the builder directly
Start free
Enterprise
Customannual
250+ hosts, regulated environments
  • Everything in Fleet
  • Runs fully offline, nothing phones home
  • Integration help for your pipeline
  • Custom contract, invoicing, procurement
  • Named support contact, agreed SLA
Talk to us

Hosts come and go, and the bill follows the machines, not the people. Decommission a box and the line item leaves with it. What a Fleet license buys today: the commercial license itself, every engine update while it runs, and a human who answers. The dashboard is on the roadmap, not on your invoice.

Questions

Questions worth asking before trusting a key scanner.

Does it read my private keys?

It reads container framing, never the secret. The wire algorithm, an ssh-keygen -lf compatible fingerprint and the key size are derived without decryption. Material never enters an event, a Debug output, or a log, and public key blobs are opt-in and never part of identity.

Will it flood me with events?

Steady state is silent. A chmod on a 40-entry authorized_keys emits 40 PermissionsChanged events by design, because entry identity is the detection model. Group by path downstream when you want file-level rollups.

Do I need an agent on every host?

No. The engine is a library plus a one-shot CLI. Scan from cron, a CI job, or the collector already running on the box. Windows path layouts are built in, and the engine builds without the scan backend when you only need the types.

What counts as a host?

Anything with an SSH directory you scan, one license seat per machine. The counter follows machines, not people, so a decommissioned box drops off at the next cycle.

What happens if the baseline is corrupt?

It is quarantined, never silently deleted. Loading an older document shape is handled explicitly, so a format change cannot masquerade as a flood of new keys. If the baseline has to reset, you see it, and the next scan is still a full audit.

The tripwire

Know the moment a key changes.

Install the engine, point it at ~/.ssh, and keep the diff. The first scan is the audit. Every scan after it is the alarm.

Three hosts free, forever. A dollar a host after that.

ssh-key-audit v0.4.0 · linux x86_64 · static musl
curl -LO https://github.com/LarsArtmann/ssh-key-monitor/releases/download/v0.4.0/ssh-key-audit-x86_64-linux-musl
curl -LO https://github.com/LarsArtmann/ssh-key-monitor/releases/download/v0.4.0/sha256sums.txt
sha256sum -c --ignore-missing sha256sums.txt && chmod +x ssh-key-audit-x86_64-linux-musl
./ssh-key-audit-x86_64-linux-musl ~/.ssh /etc/ssh

Also: aarch64-linux · windows (compile-verified, run-verified under wine) · darwin not published · release notes

No key material logged Rust 2024 edition Linux, macOS, Windows