Chapter 36 — Hands-on: SSH keys, tunnels, config, and signing
Why this chapter exists
SSH is a daily tool. Fluency with ~/.ssh/config, port forwarding, and
ssh-keygen -Y signing pays off constantly. This is the drill.
~/.ssh/config — stop typing flags
# Global-ish defaults
Host *
AddKeysToAgent yes
IdentitiesOnly yes # only offer the key I name (avoids
# "too many auth failures" lockouts)
ServerAliveInterval 60
HashKnownHosts yes
ControlMaster auto
ControlPath ~/.ssh/cm/%r@%h:%p
ControlPersist 5m
Host bastion
HostName bastion.securesh.op
User jump
IdentityFile ~/.ssh/id_ed25519
Host app-* db-*
User deploy
IdentityFile ~/.ssh/id_ed25519_work
ProxyJump bastion # <-- transparently hop via bastion
RequestTTY auto
Host github.com
User git
IdentityFile ~/.ssh/id_ed25519_github
IdentitiesOnly yes
# Then just: ssh app-01.internal (user, key, and jump are automatic)
Port forwarding — the three kinds
LOCAL forward ssh -L 5433:db.internal:5432 bastion
"expose the remote db's :5432 on my localhost:5433"
-> psql -h 127.0.0.1 -p 5433 ... reaches db.internal through the tunnel.
REMOTE forward ssh -R 8080:localhost:3000 jump
"expose MY localhost:3000 on the remote host's :8080"
-> for demos, webhooks to a laptop. (GatewayPorts on the server
controls whether :8080 is public or loopback-only there.)
DYNAMIC forward ssh -D 1080 bastion
"run a SOCKS5 proxy on localhost:1080 that exits via bastion"
-> point a browser / curl --socks5 at it to reach an internal network.
Add -N (no shell) and -f (background) for tunnel-only sessions.
Jump without forwarding anything: ssh -J bastion target
scp / sftp / rsync over SSH
# scp's syntax is legacy and footgun-prone; on OpenSSH 9+ it uses the
# SFTP protocol under the hood. Prefer sftp or rsync for real work.
sftp app-01.internal # interactive: get/put/ls/lcd
rsync -avz --delete ./site/ app-01.internal:/var/www/site/ # uses ssh
rsync -e 'ssh -J bastion' ... # rsync through a jump host
SSH message signing with ssh-keygen -Y (no GPG needed)
Since OpenSSH 8.0 you can sign arbitrary files with an SSH key — this is what
Git uses for gpg.format ssh.
cd /tmp && mkdir -p ysign && cd ysign
ssh-keygen -t ed25519 -f signer -N '' -C release@securesh.op -q
# SIGN (namespace scopes the signature's meaning; use "file" for files,
# "git" is what git uses)
echo "SecureShop release 7.0.0" > NOTES
ssh-keygen -Y sign -f signer -n file NOTES # -> NOTES.sig
# VERIFY needs an allowed-signers file mapping identity -> public key
printf '%s %s\n' release@securesh.op "$(cat signer.pub)" > allowed_signers
ssh-keygen -Y verify -f allowed_signers -I release@securesh.op \
-n file -s NOTES.sig < NOTES # -> "Good file signature..."
# tamper -> fails
echo tampered >> NOTES
ssh-keygen -Y verify -f allowed_signers -I release@securesh.op \
-n file -s NOTES.sig < NOTES # -> "Signature verification failed"
# Git signed commits with your SSH key (no GPG):
git config --global gpg.format ssh
git config --global user.signingkey ~/.ssh/id_ed25519.pub
git config --global commit.gpgsign true
# verification needs an allowed_signers file:
git config --global gpg.ssh.allowedSignersFile ~/.ssh/allowed_signers
# ~/.ssh/allowed_signers: you@example.com ssh-ed25519 AAAA...
git commit -S -m "signed" # then: git log --show-signature
Debugging SSH
ssh -v host # one -v: auth method negotiation
ssh -vv host # + kex/cipher/mac, key offering
ssh -vvv host # packet-level
# Common failures and the -v line that reveals them:
# "Permission denied (publickey)" -> "Offering public key ..." then
# "Authentications that can continue" -> server didn't accept the key:
# wrong key, not in authorized_keys, bad perms (must be 700 ~/.ssh,
# 600 the key, 644 .pub, 600 authorized_keys, and the HOME not
# group-writable -- sshd is strict).
# "too many authentication failures" -> agent offered many keys;
# set IdentitiesOnly yes + IdentityFile.
# "REMOTE HOST IDENTIFICATION HAS CHANGED" -> known_hosts mismatch (Ch 34).
# "no matching key exchange method" -> old server, modern client (or
# vice versa); check `ssh -Q kex` on both, add a KexAlgorithms line.
sudo sshd -T # dump the server's EFFECTIVE config
sudo sshd -t # validate config before reload (do this ALWAYS)
journalctl -u ssh -f # watch auth attempts (LogLevel VERBOSE helps)
Practice / drill (45 min)
Do these without looking back:
- Write a
~/.ssh/configwith abastionhost and anapp-*pattern that usesProxyJump bastion, a specificIdentityFile, andIdentitiesOnly. - Generate an
ed25519-skkey (if you have a FIDO2 key) or a passphrase- protecteded25519key. Load it into the agent with an 8-hour timeout. - Open a local forward from your
localhost:15432to a remote Postgres, through the bastion, tunnel-only (-N -f). Tear it down cleanly. - Open a dynamic (SOCKS) proxy and use
curl --socks5-hostnamethrough it. - Sign a file with
ssh-keygen -Y sign, verify it with anallowed_signersfile, tamper with the file, watch verification fail. - Configure Git to sign commits with your SSH key; make a signed commit; run
git log --show-signature. - Deliberately break
~/.sshpermissions (chmod 777 ~/.ssh) and read the exactssh -verror. Restore.
Common confusions
- "
ssh-copy-idthen it works everywhere." It appends to one host'sauthorized_keys. At scale, use config management or an SSH CA. - "
-Land-R— I'll remember which is which."-L= Local port that I connect to;-R= port on the Remote that others connect to. Mixing them up exposes the wrong service. - "scp is fine." Its quoting/globbing is a known footgun; OpenSSH itself
recommends
sftp/rsync. On 9+,scpspeaks SFTP anyway. - "Signing needs GPG."
ssh-keygen -Yand Git'sgpg.format sshsign with the SSH key you already have. - "Permission denied means wrong password." For
publickeyit usually means key not accepted or file permissions too open —sshdrefuses group/world-readable keys and writable HOME.
Check yourself
- What do
ProxyJump,IdentitiesOnly, andControlMastereach do inssh_config? - Explain
-L,-R, and-Dforwards with one concrete use each. - How do you sign and verify an arbitrary file with an SSH key, and what is the
-nnamespace for? ssh -vshows the key being offered then "Permission denied (publickey)" — name three causes.- Which command validates
sshd_configbefore you reload it?
Further reading
- Man pages:
ssh_config(5)(everyHostoption),ssh-keygen(1)(-Ysection),scp(1)note recommending sftp/rsync. - Book: SSH Mastery (2nd ed.), Lucas — config and tunneling chapters.
- Docs: Git
gpg.format sshdocumentation; GitHub's "SSH commit signature verification" guide. - Cheat sheet:
explainshell.comfor anyssh -L ...line; the Arch/Debian wiki SSH pages. - Tool:
sshuttle(VPN-over-SSH),mosh(roaming SSH),assh/storm(config managers),ssh-audit.
End of Part 8 — Milestone check
- I can name SSH's three sub-protocols and the order authentication happens
- I know why SSH already resists "harvest now, decrypt later"
- I use ed25519 keys with a passphrase and an agent
- I understand why
ProxyJumpbeats agent forwarding, and use-J - I can explain TOFU, its weak point, and how an SSH CA removes it
- I can write a hardened
sshd_configfrom memory (top 5 settings) - I built an SSH CA that issues short-lived host and user certificates
- I can set up
-L/-R/-Dforwards and a~/.ssh/configwithProxyJump - I signed and verified a file with
ssh-keygen -Yand signed a Git commit with my SSH key - I wrote the Part 8 SSH report