SSH: host keys, ed25519 keys, and why git push doesn't ask for a password

An SSH connection combines key exchange, a host key and a user signature. Looking at a real connection to GitHub using mlkem768x25519, known_hosts and trust on first use, ed25519 against RSA keys, ssh-agent, and a checklist for hardening a server.

Every time this site is deployed, deploy.sh opens an SSH connection to the server, copies the code over and runs the container swap. Every git push opens another SSH connection, to GitHub. Neither asks for a password. This post explains why, and why that's safer than using a password.

SSH is the second protocol in the series that uses all three tools: key agreement and symmetric encryption like TLS (post 5), but a completely different way of trusting the server, and users sign in with a signature instead of a password.

Roadmap: the steps of an SSH connection, a look at a real connection to GitHub, host keys and known_hosts, signing in with keys, ed25519 against RSA, ssh-agent and agent forwarding, SSH certificates, then hardening a server. The commands run with OpenSSH 10.3 on a real machine. The post only connects to GitHub, whose host keys are published openly, and prints nothing about this site's server.

The three steps of a connection

Step Job Like TLS where
1. Key exchange Both sides agree on a shared secret with temporary keys, then turn on encryption Identical: ECDHE, with forward secrecy
2. Server authentication The server signs the key-exchange result with its long-term host key Like CertificateVerify, but with no CA
3. User authentication The user proves their identity: with a key (a signature), or a password TLS rarely has this step

The biggest difference is step 2: no CA vouches that "this key belongs to that server". The sections below explain how SSH copes.

A real connection to GitHub

$ ssh -vv -o PreferredAuthentications=none [email protected]
kex: algorithm: mlkem768x25519-sha256
kex: host key algorithm: ssh-ed25519
kex: server->client cipher: [email protected] MAC: <implicit> compression: none
kex: client->server cipher: [email protected] MAC: <implicit> compression: none
Server host key: ssh-ed25519 SHA256:+DiY3wvvV6TuJJhbpZisF/zLDA0zPMSvHdkr4UvCOqU
Authentications that can continue: publickey

PreferredAuthentications=none stops right before step 3, just to observe. Line by line:

  • mlkem768x25519-sha256: a post-quantum hybrid key exchange, the same idea as X25519MLKEM768 in this site's TLS (post 4). OpenSSH picks it by default, no configuration needed. Before that, since version 9.0 (2022), OpenSSH defaulted to a different hybrid, sntrup761x25519.
  • ssh-ed25519: GitHub proves its identity with an Ed25519 signature.
  • chacha20-poly1305: data goes through an AEAD, as in TLS.
  • Authentications that can continue: publickey: GitHub only accepts key sign-ins, not passwords.

Host keys and known_hosts: trust on first use

The first time you SSH to a server, you meet this:

The authenticity of host 'github.com' can't be established.
ED25519 key fingerprint is SHA256:+DiY3wvvV6TuJJhbpZisF/zLDA0zPMSvHdkr4UvCOqU.
Are you sure you want to continue connecting (yes/no)?

There's no CA, so SSH uses TOFU (trust on first use): the first time, you decide yourself whether to trust this key. Type yes and the key goes into ~/.ssh/known_hosts. From then on, if the server presents a different key, SSH refuses to connect with the warning REMOTE HOST IDENTIFICATION HAS CHANGED!.

The model's weak spot is the first time: if a man in the middle is there at that moment, you trust their key. The right way is to compare the fingerprint with a trusted source on a different channel from SSH. GitHub publishes its fingerprints in its documentation (over HTTPS), and all three keys fetched with ssh-keyscan match that list:

256  SHA256:p2QAMXNIC1TJYWeIOttrVc98/R1BUFWu3/LiyKgUfQM github.com (ECDSA)
256  SHA256:+DiY3wvvV6TuJJhbpZisF/zLDA0zPMSvHdkr4UvCOqU github.com (ED25519)
3072 SHA256:uNiVztksCsDhcc0u9e8BujQXVUpKZIDTMczCvj3tD2s github.com (RSA)

For your own server, get the fingerprint from the VPS provider's console (usually printed in the machine's boot log), then compare it on the first connection. Or install the host key into known_hosts through another channel.

A "host key changed" warning usually has a harmless cause, such as a reinstalled server or an IP address given to another machine. But never delete the old line in known_hosts until you know for sure why the key changed.

Signing in with a key

Instead of sending a password, the user has a key pair. The public key is placed on the server in ~/.ssh/authorized_keys. At sign-in, the server asks for a signature over data bound to this session (a session id derived from the key exchange) and checks it against the public key placed there.

Compared with a password:

Password Key
What the server receives The password itself (even over an encrypted channel) A signature valid only for this session
What an impostor server (trusted by mistake on first use) gets The password, reusable on the real server Nothing reusable
Remote guessing Yes, and it happens constantly on every machine with port 22 open Not realistic (256 bits)
Reuse across places People do it A public key placed in many places is fine

A server with port 22 open to the Internet receives automated password-guessing attempts almost as soon as it's switched on. That's why the first thing to do when setting up a machine is to switch to key sign-in and turn passwords off.

ed25519 against RSA

Making a key:

ssh-keygen -t ed25519 -C "an@laptop"
ed25519 RSA 4096
Public key (.pub) 115 bytes 759 bytes
Private key (file) 432 bytes 3,401 bytes
Sign, verify Fast (post 4) Slower to sign
Random number needed when signing No (safer) No, but needs the right padding

Use ed25519 for every new key. RSA is only needed for very old systems. DSA keys have been removed from OpenSSH entirely, and the old ssh-rsa signature type (with SHA-1) has been off by default since 2021.

Two habits matter more than the choice of algorithm:

  • Set a passphrase on the private key. The key file is then encrypted (with a slow key-derivation function, the same idea as post 3), so whoever copies the file can't use it right away.
  • One key per device. Lose a laptop and you delete exactly one line in authorized_keys, instead of replacing your key everywhere. The -C comment shows which line belongs to which device.

A key can live entirely in a hardware key (ssh-keygen -t ed25519-sk with a YubiKey or other FIDO2 keys): the private key never leaves the device, and every signature needs a touch.

ssh-agent and agent forwarding

Typing the passphrase for every SSH gets tiring. ssh-agent keeps unlocked keys in RAM and signs on behalf of connections. On macOS it's tied to the Keychain. The private key never leaves the agent; programs only ask it to sign.

Agent forwarding (ssh -A) lets a remote server use the agent on your laptop, for example to git pull from GitHub right on the server. The risk: while you're connected, anyone with root on that server can ask your agent to sign too, i.e. sign into everywhere your key opens. They can't take the key, but they can use it. Safer options:

  • ProxyJump (ssh -J bastion target) when you need to go through an intermediate machine. The connection to the target is encrypted end to end from your laptop; the intermediate machine only relays bytes.
  • A separate, read-only deploy key for each repo on the server.
  • If you must forward the agent, use ssh-add -c so every signature asks for confirmation.

SSH certificates

authorized_keys and known_hosts suit a few servers and a few people. With hundreds of machines and dozens of people, adding, removing and checking fingerprints by hand doesn't scale. SSH certificates bring a CA into SSH:

  • An internal CA signs users' keys, along with the login name and a short lifetime (say 8 hours). Servers only need to trust the CA's public key (TrustedUserCAKeys), not a list of individuals.
  • The CA also signs host keys, so clients trust every server the CA signed (@cert-authority in known_hosts), and the first-use question disappears.

When someone leaves, their certificate expires by itself within hours. This is how large companies manage SSH.

Hardening a server

A checklist for a VPS like this site's server, in order of value:

Task Configuration Why
Key sign-in only PasswordAuthentication no, KbdInteractiveAuthentication no Blocks all password guessing
No direct root login PermitRootLogin no (or prohibit-password) One more step, and logs show who did what
Limit who can SSH AllowUsers deploy Service accounts can't open a shell
Firewall Only open 22, 80, 443; better yet, only allow SSH from your own IP Shrinks the attack surface
Block automated guessing fail2ban, or MaxAuthTries Less noise in the logs
Updates Turn on automatic security updates Most exploited vulnerabilities had a patch long before
Don't expose database or Docker ports Listen only on 127.0.0.1 or an internal network Post 9

Moving SSH to a port other than 22 makes the logs much quieter, but it isn't a security measure: one port scan finds it. Do it after the rows above, not instead of them.

One rule when changing SSH configuration remotely: keep one session open, apply the new configuration (sshd -t checks the syntax first), then open a second session to test. If the configuration is wrong, the first session is still there to fix it. Skipping this is the fastest way to lock yourself out of the server.

Summary

  • SSH has three steps: key exchange (temporary keys, forward secrecy), server authentication (a signature with the host key), user authentication (a signature with your key).
  • The real connection to GitHub: mlkem768x25519-sha256 (post-quantum hybrid), an ed25519 host key, chacha20-poly1305, and key sign-in only.
  • With no CA, SSH uses TOFU. Compare the fingerprint over another channel the first time. If the key changes, find out why before touching known_hosts.
  • Key sign-in: the server only receives a signature bound to the session, so an impostor server gets nothing reusable. Use ed25519, set a passphrase, one key per device.
  • Agent forwarding lets root on the other machine use your key. Prefer ProxyJump or deploy keys.
  • Server hardening: turn off passwords, no root login, AllowUsers, a firewall, updates, and always keep a session open while editing sshd_config.

The last post puts it all together on this very site: a threat model, a walk through every layer from Cloudflare to the .env file, common web vulnerabilities, what's still missing, and a checklist.