Post-Quantum SSH: Hybrid ML-KEM Key Exchange and ML-DSA Host Keys with OpenSSH 9.0+
Problem
SSH secures the control plane for virtually every serious infrastructure deployment. When you log into a production server, push a configuration change via Ansible, or pull secrets from Vault over a bastion, SSH is the channel carrying that trust. Today, all of that is protected by classical cryptography: ECDH P-256 or Curve25519 for key exchange, RSA or Ed25519 for host and user keys.
Classical asymmetric cryptography — ECDH, RSA, ECDSA — is broken by Shor’s algorithm running on a cryptographically relevant quantum computer (CRQC). No CRQC exists at scale yet, but the timeline is compressing. NIST completed its first post-quantum cryptography standardisation round in 2024, producing FIPS 203 (ML-KEM), FIPS 204 (ML-DSA), and FIPS 205 (SLH-DSA). The existence of these standards signals that planning for quantum-capable adversaries is an engineering requirement, not a thought experiment.
The specific threat is harvest-now-decrypt-later (HNDL). A nation-state adversary does not need a CRQC today to benefit from post-quantum cryptography failing. They need only to record encrypted traffic now and store it until a CRQC is available. Long-lived sensitive sessions — SSH connections to production infrastructure, secrets management systems, or financial core systems — are the highest-priority targets because the information they carried is often still sensitive years later. A session transcript from today that is decrypted in 2032 may still contain usable credentials, configuration details, or strategic information.
HNDL is not theoretical. Intelligence agencies with the budget and mandate to protect national secrets have operated bulk traffic collection infrastructure for decades. SSH sessions to government, financial, and critical infrastructure targets are obvious collection priorities.
SSH has three cryptographic components that need PQC hardening:
-
Key exchange (KEX) — the most urgent. This is where the shared session key is established at connection setup. If the key exchange is captured today and broken with a CRQC later, the entire session can be decrypted retroactively. A single passive recording is sufficient for an HNDL attack.
-
Host key algorithms — server authentication. The host presents a signed certificate or public key to prove its identity. A future quantum attacker who can break ECDSA or Ed25519 could forge host authentication, enabling active man-in-the-middle attacks. This is a concern for traffic captured today only if the connection negotiation itself is recorded; for active attacks, a CRQC is needed in real time.
-
User authentication keys — client identity. If a stored user private key is later broken by a CRQC, the attacker gains the ability to authenticate as that user. Less immediately urgent than KEX (requires recovering the private key, not just captured traffic), but part of a complete PQC migration.
OpenSSH PQC timeline:
- OpenSSH 8.5 (March 2021): introduced
sntrup761x25519-sha512@openssh.com, a hybrid of NTRU Prime sntrup761 and X25519 - OpenSSH 9.0 (April 2022): made
sntrup761x25519-sha512@openssh.comthe default first-preference KEX algorithm, making it the most widely deployed post-quantum SSH algorithm in the world - OpenSSH 9.9 (October 2024): added
mlkem768x25519-sha256, the NIST-standardised ML-KEM-768 hybrid
The distinction between sntrup761 and mlkem768x25519 matters for compliance-sensitive environments. sntrup761 (NTRU Prime) is not a NIST-standardised algorithm. It provides quantum resistance, but it is not covered by FIPS 203. For any environment subject to FIPS compliance, FedRAMP, or equivalent frameworks that require NIST-approved algorithms, mlkem768x25519-sha256 is the correct choice, not sntrup761x25519-sha512.
For environments without strict algorithm compliance requirements, sntrup761x25519-sha512 is already widely deployed and provides meaningful quantum resistance today. The practical recommendation is to prefer mlkem768x25519-sha256 where OpenSSH 9.9+ is available on both client and server, and retain sntrup761x25519-sha512 as a second preference for clients that have not yet upgraded.
Threat Model
-
Adversary 1 — Nation-state HNDL collector: A state intelligence service operates packet capture infrastructure at internet exchange points, peering connections, or via legal interception orders against cloud providers and telcos. It records encrypted SSH sessions to government contractors, financial institutions, or critical infrastructure operators. Today’s traffic is stored for future decryption once a CRQC becomes available. This adversary does not need to be present at the time of decryption; bulk storage is sufficient.
-
Adversary 2 — Insider with long-term network tap access: An insider at a managed service provider, large enterprise, or government agency has access to network recording infrastructure. The insider exfiltrates SSH session recordings targeting specific high-value accounts. The recordings sit on offline storage until quantum capability is acquired or contracted.
-
Adversary 3 — Post-quantum active attacker (future): In 5-10 years, an adversary gains access to a CRQC (via nation-state development or a future quantum computing service). This adversary can now break all previously recorded ECDH key exchanges retroactively and can also forge classical ECDSA/RSA host key signatures for active MitM attacks in real time. Organisations that have not migrated to PQC key exchange by this point expose all their historical session transcripts.
-
Objective: Decrypt SSH sessions — specifically to recover command output, credentials passed over SSH tunnels, secret payloads, and lateral movement paths through infrastructure.
-
Blast radius: An unprotected SSH session to a secrets management bastion could yield API keys, database passwords, or signing keys. An SSH session to a CI/CD controller could yield deployment credentials or code signing keys. Long-lived multiplexed sessions are especially valuable because they carry more data per captured handshake.
-
Mitigations address: Adversaries 1 and 2 are defeated by PQC key exchange — even if the session is recorded, the session key cannot be recovered by breaking the key exchange algorithm. Adversary 3 is defeated by deploying PQC key exchange now, before the CRQC becomes available.
Configuration
Checking Your OpenSSH Version and PQC Support
Before making any configuration changes, verify which OpenSSH version is running and which PQC KEX algorithms it supports:
ssh -V
# OpenSSH_9.9p1, OpenSSL 3.3.1 4 Jun 2024
# Check which KEX algorithms are available
ssh -Q kex
# Should include:
# sntrup761x25519-sha512@openssh.com
# mlkem768x25519-sha256 (OpenSSH 9.9+)
# Filter for PQC algorithms specifically
ssh -Q kex | grep -E "mlkem|sntrup"
On the server side:
# Check effective server configuration
sudo sshd -T | grep -i kex
# kexalgorithms sntrup761x25519-sha512@openssh.com,curve25519-sha256,...
# Check OpenSSH server version
sshd -V 2>&1 || ssh -V
If mlkem768x25519-sha256 does not appear in ssh -Q kex output, the client or server is running OpenSSH older than 9.9. In that case, sntrup761x25519-sha512@openssh.com is the best available PQC option.
Server-Side sshd_config: PQC Key Exchange
Add a drop-in configuration file to prefer PQC KEX algorithms:
# /etc/ssh/sshd_config.d/50-pqc-kex.conf
# Prefer ML-KEM-768 hybrid (NIST FIPS 203) first.
# Fall back to sntrup761x25519 for OpenSSH 9.0-9.8 clients.
# Retain classical algorithms for clients that cannot do PQC.
KexAlgorithms mlkem768x25519-sha256,sntrup761x25519-sha512@openssh.com,curve25519-sha256,curve25519-sha256@libssh.org,ecdh-sha2-nistp256,ecdh-sha2-nistp384,ecdh-sha2-nistp521,diffie-hellman-group-exchange-sha256
Algorithm rationale:
| Algorithm | Type | Notes |
|---|---|---|
mlkem768x25519-sha256 |
PQC hybrid (ML-KEM-768 + X25519) | NIST FIPS 203; requires OpenSSH 9.9+ on both sides |
sntrup761x25519-sha512@openssh.com |
PQC hybrid (NTRU Prime + X25519) | Default since OpenSSH 9.0; not NIST-standardised |
curve25519-sha256 |
Classical | Recommended classical fallback; constant-time |
ecdh-sha2-nistp256 |
Classical | NIST P-256; retain for HSM/appliance compatibility |
diffie-hellman-group-exchange-sha256 |
Classical | Legacy fallback only; prefer removing in a second pass |
After modifying the configuration, validate and reload:
sudo sshd -t && sudo systemctl reload ssh
For environments that want to enforce PQC-only connections (removing classical fallback entirely), first confirm that every client in your fleet supports PQC KEX — this is a migration-breaking change:
# PQC-only — do not deploy until all clients are confirmed compatible
KexAlgorithms mlkem768x25519-sha256,sntrup761x25519-sha512@openssh.com
Updating SSH Host Keys
Host keys authenticate the server to the client. Classical RSA and ECDSA host keys are vulnerable to a future quantum attacker performing active MitM (Adversary 3 above). Ed25519 host keys are not quantum-safe either — all elliptic curve discrete log-based algorithms are broken by Shor’s algorithm.
The current OpenSSH landscape for host keys:
- OpenSSH does not yet ship a native ML-DSA host key type. ML-DSA (FIPS 204, formerly Dilithium) host key support is on the OpenSSH development roadmap but had not been merged into a stable release as of early 2026. Watch the
openssh-unix-devmailing list and OpenSSH release notes. - Ed25519 host keys are the current best practice for the classical component. They are more resistant to implementation-level attacks than RSA or ECDSA and are faster. They do not provide PQC security, but they are the right classical choice to pair with PQC key exchange.
- The most impactful change you can make today is upgrading KEX, not host keys. HNDL attacks against SSH primarily target the session key established in key exchange. Host key compromise requires an active MitM at connection time, which demands a real-time CRQC — a more demanding capability than a CRQC used offline against stored ciphertexts.
Generate Ed25519 host keys if not already present:
# Check existing host keys
ls -la /etc/ssh/ssh_host_*
# Generate Ed25519 host key if not present
sudo ssh-keygen -t ed25519 -f /etc/ssh/ssh_host_ed25519_key -N ""
# Disable RSA host key if you want to force Ed25519 only
# (keep RSA for now if you have legacy clients)
# In sshd_config.d/50-host-keys.conf:
# HostKey /etc/ssh/ssh_host_ed25519_key
# HostKey /etc/ssh/ssh_host_rsa_key # Remove once legacy clients are gone
Restrict advertised host key types to deprioritise RSA:
# /etc/ssh/sshd_config.d/50-host-keys.conf
HostKey /etc/ssh/ssh_host_ed25519_key
HostKey /etc/ssh/ssh_host_rsa_key
# Restrict host-based auth algorithms to prefer Ed25519
HostKeyAlgorithms ssh-ed25519,ssh-ed25519-cert-v01@openssh.com,rsa-sha2-512,rsa-sha2-256
When ML-DSA host key support lands in a stable OpenSSH release, the migration path will be:
- Generate ML-DSA host keys alongside existing Ed25519 keys
- Add ML-DSA host keys to
HostKeylist - Distribute the new host key fingerprints (or re-sign server certificates with the ML-DSA CA key)
- Update client
known_hostsor SSH CA trust - After confirmed client support, remove classical host keys
SSH Certificate Authority Migration
If your fleet uses SSH certificate authorities (a CA key signs user or host certificates), the CA key itself is also a classical asymmetric key and will need to be migrated when ML-DSA CA keys become available in OpenSSH.
Current best practice for SSH CAs:
# Generate a new Ed25519 CA key (if migrating from RSA CA)
ssh-keygen -t ed25519 -f /etc/ssh/ca_host_key -C "host-ca-2026"
ssh-keygen -t ed25519 -f /etc/ssh/ca_user_key -C "user-ca-2026"
# Re-sign existing host certificates with the new CA
ssh-keygen -s /etc/ssh/ca_host_key \
-I "$(hostname)-2026" \
-h \
-V +52w \
/etc/ssh/ssh_host_ed25519_key.pub
# Distribute the new CA public key to all clients
# /etc/ssh/ssh_known_hosts or pushed via Ansible:
# @cert-authority *.example.com <ca_host_key.pub content>
When ML-DSA CA key support is available, follow the same pattern: generate a new ML-DSA CA key, re-sign all host certificates, update client trust anchors.
Client-Side ssh_config
Every connecting client should also prefer PQC KEX algorithms. A server offering PQC KEX only matters if the client negotiates it:
# ~/.ssh/config or /etc/ssh/ssh_config.d/50-pqc.conf
# Global default: prefer PQC hybrid algorithms
Host *
KexAlgorithms mlkem768x25519-sha256,sntrup761x25519-sha512@openssh.com,curve25519-sha256,curve25519-sha256@libssh.org,ecdh-sha2-nistp256,diffie-hellman-group-exchange-sha256
HostKeyAlgorithms ssh-ed25519,ssh-ed25519-cert-v01@openssh.com,rsa-sha2-512,rsa-sha2-256
# Legacy servers that don't support PQC KEX (network appliances, old distros)
Host legacy-switch.corp.example.com
KexAlgorithms curve25519-sha256,ecdh-sha2-nistp256,diffie-hellman-group-exchange-sha256
HostKeyAlgorithms ssh-ed25519,rsa-sha2-512,rsa-sha2-256
# Production jump hosts: use connection multiplexing to amortise PQC KEX overhead
Host jumphost.prod.example.com
ControlMaster auto
ControlPath ~/.ssh/cm-%r@%h:%p
ControlPersist 10m
KexAlgorithms mlkem768x25519-sha256,sntrup761x25519-sha512@openssh.com,curve25519-sha256
Connection multiplexing (ControlMaster) is particularly valuable with PQC key exchange. ML-KEM key generation involves generating random lattice samples, and while the overhead is small in absolute terms (see Trade-offs below), multiplexing amortises it across multiple connections sharing the same SSH session.
Fleet Inventory and Staged Rollout with Ansible
For a fleet of servers, use Ansible to audit current KEX support before making changes:
# audit-ssh-kex.yml
- name: Audit SSH KEX algorithms across fleet
hosts: all
gather_facts: false
tasks:
- name: Get current sshd KexAlgorithms
command: sshd -T
register: sshd_config
become: true
changed_when: false
- name: Extract KexAlgorithms line
set_fact:
kex_line: "{{ sshd_config.stdout | regex_search('kexalgorithms.*') }}"
- name: Report PQC status
debug:
msg: >
{{ inventory_hostname }}: PQC={{ 'mlkem768' in kex_line or 'sntrup761' in kex_line }},
ML-KEM={{ 'mlkem768' in kex_line }},
sntrup={{ 'sntrup761' in kex_line }}
Staged rollout procedure:
Phase 1 — Add PQC algorithms, keep all classical (no disruption):
# phase1-add-pqc.yml
- name: Phase 1 - Enable PQC KEX (additive change)
hosts: all
become: true
tasks:
- name: Deploy PQC KEX config
copy:
dest: /etc/ssh/sshd_config.d/50-pqc-kex.conf
content: |
KexAlgorithms mlkem768x25519-sha256,sntrup761x25519-sha512@openssh.com,curve25519-sha256,curve25519-sha256@libssh.org,ecdh-sha2-nistp256,ecdh-sha2-nistp384,ecdh-sha2-nistp521,diffie-hellman-group-exchange-sha256
validate: "sshd -t -f %s"
- name: Reload sshd
service:
name: ssh
state: reloaded
Phase 2 — Monitor for PQC negotiation success in logs:
# Watch for successful PQC KEX in auth.log
grep "kex: algorithm:" /var/log/auth.log | grep -E "mlkem|sntrup" | wc -l
# Watch for any KEX failures (clients that can't negotiate)
grep -E "no matching key exchange method|Unable to negotiate" /var/log/auth.log | tail -20
Phase 3 — Remove legacy DH algorithms after confirmed PQC adoption:
Once monitoring confirms all clients are using PQC or modern classical KEX, remove weaker algorithms (e.g., diffie-hellman-group14-sha256 and older).
known_hosts Management
If host keys change (e.g., migrating from RSA-only to Ed25519, or future ML-DSA addition), clients will encounter WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED errors. For large fleets, individual known_hosts entries are not the right tool.
Preferred approaches at scale:
CA-based host trust: Clients trust the CA public key and accept any host certificate signed by it, regardless of whether the individual host key is in known_hosts. This completely decouples host key rotation from client known_hosts management:
# /etc/ssh/ssh_known_hosts (pushed to all clients via Ansible/CM)
@cert-authority *.prod.example.com ssh-ed25519 AAAA... host-ca-2026
# sshd_config on each server
HostCertificate /etc/ssh/ssh_host_ed25519_key-cert.pub
SSHFP DNS records: Publish SSH host key fingerprints in DNS, and configure clients to verify them:
# Zone file:
jumphost.prod.example.com. IN SSHFP 4 2 <sha256-fingerprint-of-ed25519-key>
# Client ssh_config:
VerifyHostKeyDNS yes
SSHFP record type 4 is Ed25519; no SSHFP type exists for ML-DSA yet — this will need an IANA update when ML-DSA host keys ship.
Ansible-managed known_hosts: For organisations without DNS SSHFP or SSH CAs, use Ansible to distribute a canonical known_hosts file:
- name: Distribute known_hosts
copy:
src: files/ssh_known_hosts
dest: /etc/ssh/ssh_known_hosts
owner: root
mode: '0644'
Expected Behaviour
After deploying PQC KEX configuration, verify the algorithm in use with verbose SSH output:
ssh -vvv user@server.example.com 2>&1 | grep -E "kex:|KEX"
| Scenario | ssh -vvv KEX output |
Session is PQC-protected |
|---|---|---|
| Both client and server OpenSSH 9.9+ | kex: algorithm: mlkem768x25519-sha256 |
Yes — NIST ML-KEM-768 hybrid |
| Server 9.9+, client 9.0-9.8 | kex: algorithm: sntrup761x25519-sha512@openssh.com |
Yes — NTRU Prime hybrid |
| Server 9.9+, client pre-9.0 | kex: algorithm: curve25519-sha256 |
No — falls back to classical |
| Server pre-9.0, client 9.9+ | kex: algorithm: sntrup761x25519-sha512@openssh.com |
Yes — server sends sntrup first |
| Both pre-8.5 | kex: algorithm: ecdh-sha2-nistp256 |
No — no PQC support |
Full ssh -vvv handshake output showing a successful ML-KEM negotiation:
debug1: SSH2_MSG_KEXINIT sent
debug1: SSH2_MSG_KEXINIT received
debug2: local client KEXINIT proposal
debug2: KEX algorithms: mlkem768x25519-sha256,sntrup761x25519-sha512@openssh.com,curve25519-sha256,...
debug2: peer server KEXINIT proposal
debug2: KEX algorithms: mlkem768x25519-sha256,sntrup761x25519-sha512@openssh.com,...
debug1: kex: algorithm: mlkem768x25519-sha256 # <-- PQC negotiated
debug1: kex: host key algorithm: ssh-ed25519
debug1: kex: server->client cipher: chacha20-poly1305@openssh.com MAC: <implicit> compression: none
debug1: kex: client->server cipher: chacha20-poly1305@openssh.com MAC: <implicit> compression: none
The line kex: algorithm: mlkem768x25519-sha256 is the confirmation that hybrid ML-KEM key exchange was negotiated and the session key was established using a quantum-resistant algorithm.
Trade-offs
| Dimension | ML-KEM-768 hybrid | sntrup761x25519 hybrid | Classical curve25519 |
|---|---|---|---|
| Quantum resistance | Yes (NIST FIPS 203) | Yes (not NIST-standardised) | No |
| FIPS/compliance eligible | Yes | No | Depends on mode |
| Min OpenSSH version (server+client) | 9.9 | 8.5 | All versions |
| Key generation overhead vs ECDH | ~0.2ms additional | ~1.5ms additional | Baseline |
| Handshake latency overhead | Negligible (<1ms on modern hardware) | Low (2-5ms on constrained hardware) | Baseline |
| Client compatibility coverage | Narrower (newer requirement) | Broad (3+ years deployed) | Universal |
The performance difference between ML-KEM-768 and ECDH is negligible for interactive SSH sessions. The ML-KEM key encapsulation is CPU-bound but fast — modern servers handle thousands of ML-KEM operations per second. The latency cost only becomes visible in high-frequency automated SSH session establishment (e.g., Ansible running against hundreds of hosts simultaneously). For those workflows, SSH connection multiplexing (ControlMaster) amortises the cost.
sntrup761x25519-sha512 has slightly higher overhead than mlkem768x25519-sha256 because NTRU Prime is more computationally expensive than the lattice operations in ML-KEM. This is another reason to prefer mlkem768x25519-sha256 where available.
For compliance-sensitive environments: if your security policy requires NIST-approved algorithms, the only PQC KEX option today is mlkem768x25519-sha256, and it requires OpenSSH 9.9+ on both ends. Environments on older distributions (Ubuntu 22.04 ships OpenSSH 8.9; Ubuntu 24.04 ships OpenSSH 9.6) need to either pin a newer OpenSSH build or accept sntrup761x25519-sha512 as an interim measure.
Failure Modes
| Failure | Symptom | Detection | Recovery |
|---|---|---|---|
| Client doesn’t support PQC KEX | Unable to negotiate a key exchange method if classical algorithms removed from server |
grep "no matching key exchange" /var/log/auth.log |
Keep classical fallback algorithms in server KexAlgorithms during transition; upgrade clients first |
| Old Ansible/Fabric SSH library | Automation fails with KEX negotiation error | Automation logs show paramiko or libssh KEX errors |
Upgrade paramiko (supports sntrup761 from 2.11+, ML-KEM pending); pin legacy KEX for automation hosts using Match blocks |
known_hosts mismatch after host key type change |
WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! on client |
Client-side SSH warning; connection may refuse | Remove stale entry with ssh-keygen -R hostname; deploy CA-based trust or updated known_hosts file |
| sshd config syntax error | sshd refuses to reload; new connections fail with Connection refused |
sshd -t returns error before reload; or post-reload connection failure |
Always run sudo sshd -t before systemctl reload ssh; have an out-of-band console access path (AWS SSM, IPMI, cloud console) for recovery |
| Mixed fleet KEX mismatch | Some hosts accept PQC connections, some don’t; clients see intermittent failures | Monitoring shows non-deterministic SSH auth failures across fleet; Ansible tasks fail on specific hosts | Audit fleet with `sshd -T |
| OpenSSH 9.9 not available on distribution | mlkem768x25519-sha256 absent from ssh -Q kex |
`ssh -Q kex | grep mlkem` returns nothing |
| PQC KEX breaks a network appliance | Managed switch or firewall rejects SSH connection when PQC algorithms are offered | Device logs show parse error or disconnection at KEXINIT; or no error but connection hangs | Create a Match Address block in sshd_config for the appliance’s IP, offering only classical KEX; or restrict PQC in the client’s ~/.ssh/config for that host alias |