Frontend Labs
⋯

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:

  1. Write a ~/.ssh/config with a bastion host and an app-* pattern that uses ProxyJump bastion, a specific IdentityFile, and IdentitiesOnly.
  2. Generate an ed25519-sk key (if you have a FIDO2 key) or a passphrase- protected ed25519 key. Load it into the agent with an 8-hour timeout.
  3. Open a local forward from your localhost:15432 to a remote Postgres, through the bastion, tunnel-only (-N -f). Tear it down cleanly.
  4. Open a dynamic (SOCKS) proxy and use curl --socks5-hostname through it.
  5. Sign a file with ssh-keygen -Y sign, verify it with an allowed_signers file, tamper with the file, watch verification fail.
  6. Configure Git to sign commits with your SSH key; make a signed commit; run git log --show-signature.
  7. Deliberately break ~/.ssh permissions (chmod 777 ~/.ssh) and read the exact ssh -v error. Restore.

Common confusions

  • "ssh-copy-id then it works everywhere." It appends to one host's authorized_keys. At scale, use config management or an SSH CA.
  • "-L and -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+, scp speaks SFTP anyway.
  • "Signing needs GPG." ssh-keygen -Y and Git's gpg.format ssh sign with the SSH key you already have.
  • "Permission denied means wrong password." For publickey it usually means key not accepted or file permissions too open — sshd refuses group/world-readable keys and writable HOME.

Check yourself

  1. What do ProxyJump, IdentitiesOnly, and ControlMaster each do in ssh_config?
  2. Explain -L, -R, and -D forwards with one concrete use each.
  3. How do you sign and verify an arbitrary file with an SSH key, and what is the -n namespace for?
  4. ssh -v shows the key being offered then "Permission denied (publickey)" — name three causes.
  5. Which command validates sshd_config before you reload it?

Further reading

  • Man pages: ssh_config(5) (every Host option), ssh-keygen(1) (-Y section), scp(1) note recommending sftp/rsync.
  • Book: SSH Mastery (2nd ed.), Lucas — config and tunneling chapters.
  • Docs: Git gpg.format ssh documentation; GitHub's "SSH commit signature verification" guide.
  • Cheat sheet: explainshell.com for any ssh -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 ProxyJump beats 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_config from memory (top 5 settings)
  • I built an SSH CA that issues short-lived host and user certificates
  • I can set up -L / -R / -D forwards and a ~/.ssh/config with ProxyJump
  • I signed and verified a file with ssh-keygen -Y and signed a Git commit with my SSH key
  • I wrote the Part 8 SSH report

Generated from real-life-guide.md · 8 Oct 2026 16:36