Chapter 34 — Keys, agents, and host verification
In one sentence
SSH authentication is signatures both ways: the server proves itself with its
host key (which you verify on first contact and pin), and you prove yourself
with your user key (whose public half sits in the server's
authorized_keys).
Generating a user key — pick Ed25519
ssh-keygen -t ed25519 -C "you@laptop-2026"
# -> ~/.ssh/id_ed25519 (PRIVATE -- 0600, never leaves the machine)
# ~/.ssh/id_ed25519.pub (PUBLIC -- copy this around freely)
| Key type | Use it? | Notes |
|---|---|---|
| ed25519 | Yes — default choice | small, fast, no parameter footguns (Ch 20) |
| ecdsa | Avoid for new keys | NIST curve; the ECDSA k footgun; no real upside over ed25519 |
| rsa (>=3072) | Only for old servers | works everywhere; large; ensure rsa-sha2-256/512, never ssh-rsa (SHA-1) |
| ecdsa-sk / ed25519-sk | Yes, for humans | -sk = backed by a FIDO2 hardware key (YubiKey, etc.); private key can't be exfiltrated |
| dsa | Never | removed from OpenSSH |
Always set a passphrase on the private key. It encrypts the key file at rest (OpenSSH uses bcrypt-KDF + a stream cipher), so a stolen laptop doesn't equal a stolen key. The agent (below) means you type it once per session.
The SSH agent
ssh-agent holds your DECRYPTED private keys in memory and does the
signing on request, so you unlock once instead of per-connection.
eval "$(ssh-agent -s)" # start it (your desktop usually has one)
ssh-add ~/.ssh/id_ed25519 # unlock and load (prompts for passphrase)
ssh-add -l # list loaded keys
ssh-add -t 8h ~/.ssh/id_ed25519 # auto-expire after 8 hours
ssh-add -D # drop all keys now
macOS: `ssh-add --apple-use-keychain` stores the passphrase in Keychain.
Agent forwarding — convenient, and a real risk
ssh -A jump-host # forwards your agent socket to the remote
Now from jump-host you can `ssh` onward without copying keys there.
BUT: anyone with root on jump-host (or who compromises it) can use
your forwarded agent to authenticate AS YOU to anything your keys
open, for as long as you're connected. This is how attackers
pivot through bastions.
SAFER ALTERNATIVES:
* ProxyJump (-J): ssh -J jump-host target
The jump host only forwards bytes; it never sees your agent or keys.
Use this. It's the modern answer.
* ssh-add -c : require confirmation for each signing operation
* per-host agent forwarding only where you truly need it, never by
default, never to hosts you don't fully trust.
Rule: -J (ProxyJump), not -A (agent forwarding), unless you have a specific
reason.
Host verification: trust on first use (TOFU)
The web has CAs vouch for server identity. Base SSH does not — it uses TOFU:
FIRST connection to a host:
The authenticity of host 'server (203.0.113.5)' can't be established.
ED25519 key fingerprint is SHA256:D7hjS2f3jYzxkSBEqJULOG3wQDOQiUnR4KHh...
Are you sure you want to continue connecting (yes/no)?
If you type "yes", the host's public key is pinned in ~/.ssh/known_hosts.
EVERY later connection: the presented host key is compared to the
pinned one.
* match -> connect silently
* MISMATCH -> big scary warning, connection REFUSED:
"REMOTE HOST IDENTIFICATION HAS CHANGED!" "POSSIBLE DNS SPOOFING"
-> could be a reinstalled server... or a man-in-the-middle. Verify
out of band before removing the old line.
The weak moment is that first "yes". If an attacker is in the path on your very first connection, you pin their key. Mitigations:
* Verify the fingerprint OUT OF BAND on first connect: cloud console,
provisioning output, a colleague, config management.
* Distribute known_hosts via config management (Ansible/Chef/MDM) so
hosts are pre-trusted -- no human TOFU prompt at all.
* Publish host keys in DNS with SSHFP records + DNSSEC, and set
`VerifyHostKeyDNS yes`.
* Use an SSH CA (Chapter 35): sign host keys, put ONE line
(@cert-authority) in known_hosts, and TOFU disappears entirely.
authorized_keys — how the server decides who's in
~/.ssh/authorized_keys on the server, one public key per line.
Optional per-key RESTRICTIONS (use them):
restrict,pty,command="/usr/bin/backup" ssh-ed25519 AAAA... backup-bot
from="10.0.0.0/8",no-agent-forwarding ssh-ed25519 AAAA... alice
no-port-forwarding,no-X11-forwarding,command="git-shell" ssh-ed25519 ...
restrict = "deny everything, then add back only what I list". Start there.
command="..." = force a single command regardless of what the client asks
-> the basis of locked-down deploy/backup/git accounts.
Manage this file with configuration management, not by hand. Deprovisioning a person = remove their key from every host, so keep it centralised.
Practice (35 min)
cd /tmp && rm -rf sshlab && mkdir sshlab && cd sshlab
# 1. Make two keys and inspect them
ssh-keygen -t ed25519 -f alice -N 'correct horse' -C alice@lab -q
ssh-keygen -t ed25519 -f bot -N '' -C deploy-bot -q
ssh-keygen -lf alice.pub # SHA256 fingerprint
ssh-keygen -lf alice.pub -E md5 # legacy MD5 form
ssh-keygen -lvf alice.pub # + randomart (visual fingerprint)
# 2. See the encrypted-at-rest private key format
head -1 alice # -----BEGIN OPENSSH PRIVATE KEY-----
ssh-keygen -y -f alice # derive .pub from private (needs passphrase)
# 3. Agent workflow
eval "$(ssh-agent -s)"
SSH_ASKPASS='' ssh-add alice # will prompt for 'correct horse'
ssh-add -l
ssh-add -c bot # load with confirm-on-use
ssh-add -D # clear
# 4. TOFU + known_hosts, locally
# (start a throwaway sshd on a high port -- or just use `localhost`)
ssh -o UserKnownHostsFile=./kh -o StrictHostKeyChecking=ask localhost true
cat ./kh # the pinned host key line
# now simulate a key change: edit one base64 char in ./kh, reconnect,
# and watch the "IDENTIFICATION HAS CHANGED" refusal.
# 5. Restricted authorized_keys
printf 'restrict,command="echo locked-down; id" %s\n' "$(cat bot.pub)" \
>> ~/.ssh/authorized_keys
ssh -i bot -o IdentitiesOnly=yes localhost "rm -rf /" # runs the forced cmd, NOT rm
# remove that line afterwards.
Common confusions
- "ecdsa is fine, it's a NIST standard." For SSH user keys, prefer ed25519:
no
kfootgun, smaller, faster, and it's OpenSSH's default. - "Agent forwarding (
-A) is how you use a bastion." No — that exposes your agent to the bastion. UseProxyJump(-J). Reserve-Afor trusted hosts where you truly need onward auth. - "The known_hosts warning is just noise from a rebuild." Sometimes. But it's exactly what a MITM looks like. Verify out of band every time.
- "A passphrase on the key slows me down." The agent means one unlock per session. An unencrypted private key on disk is a credential anyone who reads the file now owns.
- "authorized_keys is just a list of keys." It's a policy file —
restrict,command=,from=turn a shell account into a single-purpose, network-scoped robot.
Check yourself
- Which key type for a new SSH user key, and why not ECDSA?
- What does the SSH agent do, and what does agent forwarding expose?
- What should you use instead of
-Ato hop through a bastion? - Explain TOFU and where its trust weakness is. Name two ways to remove the first-connect prompt.
- What does
restrictdo inauthorized_keys, and what iscommand=for?
Further reading
- Article: "SSH Agent Forwarding considered harmful" — Vincent Bernat's blog;
and the OpenSSH docs on
ProxyJump. - Guide: Mozilla OpenSSH guidelines (
infosec.mozilla.org/guidelines/openssh) — concretessh_config/sshd_confighardening. - Docs:
ssh-keygen(1)(the-skFIDO2 section),ssh-add(1)(-c,-t). - Video: "SSH keys, agents, and certificates" talks from BSDCan / LISA; smallstep's SSH blog series.
- Tool:
ssh-audit(github.com/jtesta/ssh-audit) — grades your client and server config.