Why the pieces are the way they are, what is deliberately not done, and where this is fragile.
A forced command= in authorized_keys only runs once the client opens a
session channel. A client that authenticates and disconnects, or runs
ssh -N (including ssh -N -L ...), never opens one — so the forced command
never runs and, with it alone, nothing at all would be reported. That is not
an exotic case: confirming a stolen key works, without doing anything with it
yet, is exactly what a careful intruder does first.
The auth-log watcher closes that gap. The canary account is used by nothing
else, so every Accepted publickey for <account> line in sshd's log is a hit
by definition, whatever the client does next.
Keeping both is not redundancy for its own sake — they fail differently:
| Forced command | Auth-log watcher | |
|---|---|---|
| Depends on | sshd's command=, the account's shell |
sshd's log wording, journald, the service running |
| Knows the label from | its own argument | the key fingerprint |
| Survives | a reworded log line | a broken authorized_keys, a wrong shell |
During this project's own deployment, a nologin shell silently killed the
forced command — and the watcher still reported every hit. That is the
argument for two, in one incident.
Both call send_alert(), which rate-limits per label, so a normal login trips
both and still produces one mail.
Not one mechanism — every path is closed, and command= is only the last one.
| Path | Why it is closed |
|---|---|
| Password over SSH | PasswordAuthentication no, and the account's password field is ! (locked) |
| Keyboard-interactive | KbdInteractiveAuthentication no |
| Some other key | authorized_keys is root-owned in a root-owned .ssh, in a root-owned home. The account cannot add, edit, replace or rename it |
| The bait key | sshd runs $SHELL -c "/opt/canary/trigger.sh <label>". The client's requested command lands in SSH_ORIGINAL_COMMAND and is only ever logged — never executed, never passed to a shell |
| PTY, port/agent/X11/socket forwarding | restrict in the key, plus DisableForwarding yes and PermitTTY no in the Match block |
su to the account |
Locked password; only root could, and root has no reason to |
sudo |
No sudo rule grants it |
| Local console | Locked password |
| Cron as the account | Only the selftest line; the crontab is owned by root |
The load-bearing rows are the first two. Even if command= vanished, nobody
reaches the account without the private key — and whoever has that key is
precisely who you want to hear about.
trigger.sh never evaluates SSH_ORIGINAL_COMMAND. It is logged after
control characters are stripped, and HTML-escaped in the mail. Do not change
that.
/usr/sbin/nologin is the intuitive choice and it is wrong here: sshd runs a
forced command through the login shell, so nologin means the trigger never
runs, nothing is logged, no mail is sent, and the client is told
This account is currently not available. The canary is dead and announces
itself in the same breath.
The protections nologin was standing in for are the locked password, disabled
password authentication, and the absence of any sudo rule — all verified by
bin/install-server.sh before it will install anything.
Tempting: enforcement would live in /etc/passwd (root-owned) rather than in
authorized_keys. But:
- The label dies. sshd invokes the shell as
shell -c "<client command>", sotrigger.shwould receive-cas$1instead of its label. Every key would report the same wrong label, and knowing which planted copy fired is the main thing the alert is for. - The selftest cron breaks. cron uses the account's passwd shell, so the
monthly run would execute
trigger.shinstead of the alert. - Debugging gets unpleasant — every
su -swalks into the trap.
Instead, authorized_keys is made root-owned, which puts enforcement outside
the account's reach without giving up the label.
Denying the pty makes the ssh client print
PTY allocation request failed
before the session hangs. It is tempting to grant restrict,pty to remove
that line, on the theory that a silent hang is better camouflage. It was
briefly done here, and then reverted, because the argument does not survive
being looked at:
The message is not a tell. It says the account is restricted — which is
exactly what a backup or deploy service account is. Anyone who has used a
forced-command deploy key, git-shell, or an rsync-only account has seen it.
It corroborates the cover story rather than breaking it. Whatever is unusual
about the session is the silent three-minute hold afterwards, and that is
there with or without a pty.
A pty is real surface, small but not zero. It allocates a /dev/pts entry
and a line discipline, which means the client can send control characters:
Ctrl-C delivers SIGINT to the process group and ends CANARY_HOLD_SECONDS
early. It also consumes a finite kernel resource per held session, giving a
flood of connections something to exhaust that they otherwise could not. And
it exercises more code in sshd and the kernel than refusing the request does.
It grants nothing useful in exchange: no shell is started, trigger.sh never
reads stdin, and everything else restrict blocks stays blocked. So the
trade is measurable surface against a cosmetic gain that is not even clearly
a gain. Keep no-pty — that is, plain restrict in the key and PermitTTY no
in the Match block.
It looks like the obvious second layer. It is not: ForceCommand overrides the
per-key command= including its argument, so every key collapses to one
unlabeled trigger. The four forwarding directives in the Match block do not
have this problem, because they are independent of which command runs.
trigger.shsilences stdout and stderr on its first line. sshd forwards a forced command's output to the client over the session channel, so a bash diagnostic, a Python traceback naming these paths, or an SMTP error would land on the intruder's terminal. Everything reports throughlogger(1)instead. Do not add anecho.- The alert is sent before the hold, never after.
CANARY_HOLD_SECONDSwastes the intruder's time and buys a reaction window; it must never gate the notification. - Mail delivery is synchronous. The process exists to send one mail and exit. A background thread would only add a way to fail silently.
- The rate limit fails open. Any problem reading or writing
/var/lib/canaryresults in the mail being sent, never suppressed. It caps mail, never logging: every hit reaches syslog regardless. - Delivery failures are loud in the log.
send_email()returns an error string rather than raising, and the caller logsALERT DELIVERY FAILED. An exception would reach the client; a swallowed error would reach nobody. - Standard library only, no shared code with other projects. An earlier
version imported a neighbouring app's mail module. It bought one function
and cost a copy that drifts, a dependency on that app's file permissions,
and a config surface where a missing key silently produced a broken SMTP
default. Twenty-five lines of
smtplibare cheaper.
Developed and tested on Ubuntu 24.04, OpenSSH 9.6p1, Python 3.12.3.
Hard requirements — the design does not work without these:
- systemd + journald. The watcher runs
journalctl -u <unit> -f. On a host with only rsyslog you would need to re-point it at/var/log/auth.log; the parsing itself would not change. The forced-command detector does not care. - OpenSSH ≥ 7.2 for
restrict. Older versions need the explicitno-agent-forwarding,no-port-forwarding,no-pty,no-user-rc,no-X11-forwardinglist — which is exactly the list that silently missesAllowStreamLocalForwarding. - OpenSSH ≥ 6.8 for SHA256 fingerprints in the log. Older versions log MD5
and the watcher's fingerprint→label lookup finds nothing (it still alerts,
as
unknown-fingerprint). - Python ≥ 3.7 (
subprocesstext=, f-strings,EmailMessage). /dev/logfor syslog. Without it, logging degrades to nothing rather than crashing — deliberate, but you lose the log trail.
Distribution-specific, easy to adjust:
CANARY_SSHD_UNIT—ssh.serviceon Debian/Ubuntu,sshd.serviceon RHEL/Fedora/Arch. Configurable; verify withsystemctl list-units --type=service | grep -i ssh.trigger.shcalls binaries by absolute path (/usr/bin/logger,/bin/date,/usr/bin/timeout, …) on purpose — never trust aPATHnear a forced command. The paths are the Debian/Ubuntu layout; on a distro without usrmerge some will differ and the trigger will fail loudly in syslog.adduser --systemin the setup docs is Debian/Ubuntu.useradd -ris the equivalent elsewhere.passwd -SprintsLon Debian/Ubuntu andLKon RHEL; the installer accepts both.
The fragile part. canary_authwatch.py finds hits by matching sshd's
English log line:
Accepted publickey for <account> from <ip> port <n> ssh2: ED25519 SHA256:<fp>
That wording is not an API. It has been stable for many years and sshd does not localise its logs, but a future OpenSSH could reword it and the watcher would go quiet with no error anywhere — the worst failure mode there is. Two things reduce the exposure:
- The forced-command detector does not parse anything, so a reworded log line costs you the auth-only case, not all detection.
bin/selftest.shmakes a real connection and asserts that both detectors reported it. It is the only check that catches this. The monthly cron on the host cannot — it proves the alert path, not the detection path.
Filtering the journal by unit rather than by syslog tag is deliberate for
the same reason: on OpenSSH ≥ 9.8 that line is written by the sshd-session
child rather than by sshd, so a tag filter would have broken on upgrade
while a unit filter does not.
Worth knowing before you go looking in the wrong place: sshd does not log a
refused forwarding channel at the default LogLevel INFO. Measured on
OpenSSH 9.6p1 — a refused -R, -L or -D leaves no trace in the server's
journal beyond the ordinary Accepted publickey line. The evidence is on the
client, and the alert is what tells you server-side.
# remote forward: fails immediately, one command
ssh -o ControlMaster=no -o ControlPath=none -o IdentitiesOnly=yes \
-o ExitOnForwardFailure=yes -N -R 19999:127.0.0.1:22 \
-i keys/canary_<label> <account>@<host>
# -> "Error: remote port forwarding failed for listen port 19999", exit 255
# SOCKS proxy: the realistic pivot attempt. Terminal 1:
ssh -o ControlMaster=no -o ControlPath=none -o IdentitiesOnly=yes \
-N -D 19999 -i keys/canary_<label> <account>@<host>
# Terminal 2:
curl -s --max-time 5 --socks5 127.0.0.1:19999 http://example.com; echo $?
# -> exit 97 (CURLE_PROXY, "proxy handshake error"): ssh accepted the local
# SOCKS connection, sshd then refused to open the channel.
# Terminal 1 prints: channel N: open failed: administratively prohibitedBoth use -N, so no session channel is opened and trigger.sh never runs —
the alert comes from the auth-log watcher alone. That makes this a good test of
the second detector as well as of the forwarding block.
- No fail2ban-style blocking. The point is to learn that a secret leaked, not to stop this one connection. Blocking would also tell the intruder they were detected.
- No shell emulation or fake filesystem. That is a honeypot: much more code, much more attack surface, and it answers a different question.
- No inbound network service. The only listener involved is the sshd you already run.
- No alerting on failed authentication. Every internet-facing host sees constant scanner traffic; only a successful login with a key that should not exist is a signal.
- Once a connection is up, everything after authentication is silent. You
get one alert, when the key is used. An intruder who stays connected and
keeps probing — a refused SOCKS or port forward for every request they push
through it — generates no further alert, and no server-side log line either:
measured on OpenSSH 9.6p1, a refused forwarding channel produces zero
entries in the journal at
LogLevel INFO, while the client seeschannel N: open failed: administratively prohibitedeach time. So the number of alerts tells you nothing about how hard someone is trying. The first alert is the one that matters; treat it as "this key is burned", not as a live activity feed. - A stolen key used against a different host is invisible. The trap is on this host only.
- Detection lag equals mail delivery. No push, no paging. If minutes matter,
point
CANARY_NOTIFY_EMAILat a gateway that pages you. - The intruder learns the account and host from the bait itself — that is what makes it bait. They learn nothing more: no shell, no files, no forwarding.
journalctl -fcan drop messages under journald's rate limit if the host is flooded with log traffic. Not observed in practice, but it is a way for a hit to be missed; the forced-command detector is unaffected.