fix(ssh): build KexAlgorithms from what OpenSSH supports, add classic opt-in

Hardened hosts rejected clients that implement the very same key exchange. The
KEX list was assembled from version arithmetic and emitted only the
standardised spellings:

    KexAlgorithms mlkem768x25519-sha256,sntrup761x25519-sha512

OpenSSH called that hybrid sntrup761x25519-sha512@openssh.com before the method
was standardised (8.5, in the default proposal from 8.9) and
sntrup761x25519-sha512 after (9.9), and KEXINIT matches names byte-exactly with
no alias resolution -- so every client older than the rename was refused with
"no matching key exchange method found" despite implementing the algorithm. The
same arithmetic was a latent server-side bug: on OpenSSH 9.0-9.8 it wrote the
post-standardisation name into sshd_config, which those builds do not know, and
sshd fatals on an unknown KexAlgorithms token rather than starting.

Ask the binary instead of guessing. oslib gains kex_supported(),
ssh_kex_pq_list(), ssh_kex_classic_list(), ssh_kex_list() and ssh_kex_has_pq(),
which filter candidates through `ssh -Q kex` and offer every spelling the host
actually has. Version thresholds are gone, and with them both failure modes --
including on distros whose backports make the version string meaningless.

SSH_ALLOW_CLASSIC_KEX=1 (off by default) additionally offers curve25519-sha256
and its @libssh.org spelling. Some clients have no PQ method at all: notably
Windows' in-box ssh.exe, which is not merely old -- Microsoft's fork compiles
sntrup761 out because it needs C99 VLAs that MSVC lacks, so even a fully patched
9.5p2 reports zero PQ methods. The knob is a real trade and says so in the
warning, the generated sshd_config comment, and the README: such a session is
safe against a classical attacker but has no store-now-decrypt-later protection.
Modern clients still negotiate PQ, since the client's preference order decides.

Three defects found reviewing the above, fixed here:

- the printed pre-reload verification command pinned the server's full list via
  `-o KexAlgorithms=`, which ssh rejects at option-parse time when the client
  lacks any one name. That made the one safety gate before a wholesale
  sshd_config swap a false negative for exactly the clients this commit admits.
  Dropped, matching harden-jumphost.sh.
- the no-PQ branch was unreachable: without the opt-in the classical names are
  never collected, so a host with no PQ hybrid died reporting "no usable key
  exchange method" instead of the actionable message written for it. The branch
  now keys off a separate PQ probe, and the empty-list die is narrowed to a
  genuinely empty `ssh -Q kex`.
- SSH_VER is cosmetic but its grep could abort the whole run under pipefail on
  any banner that does not match (vendor forks, OpenSSH_for_Windows_9.5p2) --
  silently, with no message. Guarded.

Wired through cloud-init/base.yml and jumphost.yml, since harden-ssh.sh rewrites
sshd_config wholesale on every run and a hand edit there does not survive.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-10 22:03:07 -05:00
co-authored by Claude Opus 5
parent 339c62a1b0
commit a3843d3d85
6 changed files with 213 additions and 35 deletions
+50 -1
View File
@@ -99,7 +99,7 @@ deployments/<name>/ # one folder per stack
| Script | What it does |
|--------|--------------|
| [`setup-host.sh`](scripts/setup-host.sh) | Set hostname per the naming schema (derives FQDN + Node ID) and render the shared MOTD with auto-computed border spacing. |
| [`harden-ssh.sh`](scripts/harden-ssh.sh) | SSH hardening: post-quantum hybrid KEX, fresh Ed25519 host keys, key-only auth, external SFTP subsystem, sshguard. |
| [`harden-ssh.sh`](scripts/harden-ssh.sh) | SSH hardening: post-quantum hybrid KEX (every spelling the host's OpenSSH supports; `SSH_ALLOW_CLASSIC_KEX=1` adds a curve25519 fallback for clients with no PQ method), fresh Ed25519 host keys, key-only auth, external SFTP subsystem, sshguard. |
| [`harden-jumphost.sh`](scripts/harden-jumphost.sh) | Bastion hardening on top of `harden-ssh`: `ssh-admins` (shell) vs `ssh-jumpers` (ProxyJump-only) with a PermitOpen allow-list. |
| [`harden-firewall.sh`](scripts/harden-firewall.sh) | Deny-by-default host firewall: **iptables** on Alpine/Debian, **firewalld** on Alma/RHEL (set `FW_BACKEND` to override), **skipped on Proxmox** (`pve-firewall` owns the ruleset). Loopback, established, ICMP, SSH (configurable port) + registered ports; persisted natively (no boot hook). Same `allow`/`deny`/`list`/`disable` sub-commands on both. |
| [`sshuser.sh`](scripts/sshuser.sh) | Add/edit/remove SSH users on a hardened jump host (Gum TUI or CLI flags). Installed standalone as `sshuser`. |
@@ -166,6 +166,55 @@ path, hostname, boot hooks, and the sshguard log source/backend.
**simplex** remains **Alpine-targeted** — it depends on `awall` and Tor hidden
services with Alpine-specific wiring, so it isn't part of the tri-distro set.
### Key exchange and old clients
The KEX list is built from what the host's OpenSSH actually supports (`ssh -Q kex`),
not from its version number, and it offers **every spelling** of each algorithm.
That matters: OpenSSH called the same hybrid `sntrup761x25519-sha512@openssh.com`
before the method was standardised and `sntrup761x25519-sha512` after, and SSH
matches algorithm names as exact strings — so a server offering only the new
spelling rejects a client implementing the identical algorithm under the old one,
with `Unable to negotiate ... no matching key exchange method found`.
Client floor for a PQ-only host, from the OpenSSH release notes:
| Client | Result |
|---|---|
| 9.9+ | works — knows both spellings, and ML-KEM |
| 8.9 9.8 | works — its default proposal carries `sntrup761x25519-sha512@openssh.com` |
| 8.5 8.8 | has the algorithm but does **not offer it** by default; needs `KexAlgorithms +sntrup761x25519-sha512@openssh.com` client-side |
| < 8.5 | no PQ hybrid exists — locked out by design |
| **Windows in-box `ssh.exe`** | **locked out at every version, including a fully-patched 9.5p2** |
The Windows case is not an age problem, which makes it easy to misdiagnose: Microsoft's
fork **compiles `sntrup761` out**, because it needs C99 variable-length arrays that MSVC
does not support (Win32-OpenSSH #2140, #2391). So a fully-patched Windows 11 reports
`OpenSSH_for_Windows_9.5p2` — new enough on paper — and still lists zero PQ methods in
`ssh -Q kex`. Two ways out, in order of preference:
1. **Use a client that has PQ.** Check what is already on the box before installing
anything — Git for Windows bundles genuine upstream OpenSSH:
```powershell
& "$env:ProgramFiles\Git\usr\bin\ssh.exe" -Q kex | Select-String 'mlkem|sntrup'
```
If that prints anything, use that binary and install nothing. Otherwise
`winget install --id Microsoft.OpenSSH.Preview` (10.0.0.0p2 added both algorithms).
**`Add-WindowsCapability` does not help** — it installs the same in-box 9.5p2. Mind
the `PATH` order: the MSI appends `C:\Program Files\OpenSSH`, but
`C:\Windows\System32\OpenSSH` is already ahead of it, so a bare `ssh` still resolves
to the old binary. Confirm with `(Get-Command ssh).Source`.
2. **`SSH_ALLOW_CLASSIC_KEX=1`** — also offer `curve25519-sha256` (and its
`@libssh.org` spelling). Off by default. A session that negotiates it is secure
against a classical attacker but has **no** store-now-decrypt-later protection.
Modern clients still pick a PQ method, because the client's preference order
decides. Set it per host rather than fleet-wide, and drop it once the old client
is gone.
> `harden-ssh.sh` rewrites `/etc/ssh/sshd_config` wholesale on every run, so hand
> edits to that file do not survive a re-run. Use the knob, not `sed`.
## Host firewall
[`scripts/harden-firewall.sh`](scripts/harden-firewall.sh) installs a
+4
View File
@@ -29,6 +29,9 @@ runcmd:
DATACENTER="Globally Everywhere"
SSH_PORT=22
ALLOWED_IP= # optional: whitelist your client IP in sshguard
SSH_ALLOW_CLASSIC_KEX=0 # 1 = also offer curve25519-sha256 for clients
# with no post-quantum KEX (old Windows ssh.exe);
# costs store-now-decrypt-later protection
ENABLE_FIREWALL=1 # deny-by-default host firewall (0 to skip;
# always skipped on Proxmox -- pve-firewall owns it)
OPEN_PORTS="" # extra inbound ports, e.g. "80/tcp 443/tcp"
@@ -54,5 +57,6 @@ runcmd:
# SSH hardening (key-only, PQ KEX, sshguard) + deny-by-default host firewall.
SSH_PORT="$SSH_PORT" ALLOWED_IP="$ALLOWED_IP" \
SSH_ALLOW_CLASSIC_KEX="$SSH_ALLOW_CLASSIC_KEX" \
ENABLE_FIREWALL="$ENABLE_FIREWALL" OPEN_PORTS="$OPEN_PORTS" \
FORCE=1 bash scripts/harden-ssh.sh
+4
View File
@@ -25,6 +25,9 @@ runcmd:
DATACENTER="Globally Everywhere"
SSH_PORT=22
ALLOWED_IP= # optional: whitelist your client IP
SSH_ALLOW_CLASSIC_KEX=0 # 1 = also offer curve25519-sha256 for clients
# with no post-quantum KEX (old Windows ssh.exe);
# costs store-now-decrypt-later protection
ENABLE_FIREWALL=1 # deny-by-default host firewall (0 to skip;
# always skipped on Proxmox -- pve-firewall owns it)
JUMP_TARGETS="10.0.0.5:22 10.0.0.6:22" # hosts jumpers may ProxyJump to
@@ -57,6 +60,7 @@ runcmd:
# Bastion hardening (admins shell + jumpers ProxyJump whitelist + optional
# login notifications).
SSH_PORT="$SSH_PORT" ALLOWED_IP="$ALLOWED_IP" JUMP_TARGETS="$JUMP_TARGETS" \
SSH_ALLOW_CLASSIC_KEX="$SSH_ALLOW_CLASSIC_KEX" \
ENABLE_FIREWALL="$ENABLE_FIREWALL" \
NTFY_URL="$NTFY_URL" NTFY_TOKEN="$NTFY_TOKEN" NTFY_EMAIL="$NTFY_EMAIL" NTFY_REGION="$NTFY_REGION" \
FORCE=1 bash scripts/harden-jumphost.sh
+41 -13
View File
@@ -77,19 +77,47 @@ fi
# ----------------------------------------------------------------------------
# 2. PQ KEX detection
# ----------------------------------------------------------------------------
log "Checking OpenSSH version supports PQ KEX..."
SSH_VER=$(ssh -V 2>&1 | grep -oE 'OpenSSH_[0-9]+\.[0-9]+' | head -1 | sed 's/OpenSSH_//')
SSH_MAJOR=${SSH_VER%%.*}
SSH_MINOR=${SSH_VER##*.}
HAS_MLKEM=0; HAS_SNTRUP=0
[[ $SSH_MAJOR -gt 9 || ( $SSH_MAJOR -eq 9 && $SSH_MINOR -ge 0 ) ]] && HAS_SNTRUP=1
[[ $SSH_MAJOR -gt 9 || ( $SSH_MAJOR -eq 9 && $SSH_MINOR -ge 9 ) ]] && HAS_MLKEM=1
[[ $HAS_SNTRUP -eq 1 || $HAS_MLKEM -eq 1 ]] || die "OpenSSH ${SSH_VER} has no PQ KEX. Need >= 9.0."
log "OpenSSH ${SSH_VER}: ML-KEM=${HAS_MLKEM} sntrup761=${HAS_SNTRUP}"
log "Checking which key exchange methods this OpenSSH supports..."
# Cosmetic only -- every decision below comes from `ssh -Q kex`, not this. A build
# whose banner does not match (OpenSSH_for_Windows_9.5p2, vendor forks) must not
# abort the run: without the guard, grep's non-match fails the pipeline under
# pipefail and set -e kills the script here with no message at all.
SSH_VER=$(ssh -V 2>&1 | grep -oE 'OpenSSH_[0-9]+[.][0-9]+' | head -1 | sed 's/OpenSSH_//' || true)
KEX_LIST=""
[[ $HAS_MLKEM -eq 1 ]] && KEX_LIST="mlkem768x25519-sha256"
[[ $HAS_SNTRUP -eq 1 ]] && KEX_LIST="${KEX_LIST:+$KEX_LIST,}sntrup761x25519-sha512"
# Ask the binary what it supports rather than deriving it from the version -- the
# same algorithm has two spellings and guessing wrong either breaks sshd_config or
# locks out clients that implement it under the other name. See oslib.
KEX_PQ="$(ssh_kex_pq_list)"
KEX_LIST="$(ssh_kex_list)"
if [[ -n "$KEX_PQ" ]]; then
if [[ "${SSH_ALLOW_CLASSIC_KEX:-0}" == "1" ]]; then
KEX_NOTE='# --- Key exchange: post-quantum hybrid + classical fallback ---
# curve25519-sha256 is offered for clients too old for any PQ method
# (SSH_ALLOW_CLASSIC_KEX=1). A session that negotiates it has NO store-now-
# decrypt-later protection -- drop the fallback once those clients are gone.'
warn "SSH_ALLOW_CLASSIC_KEX=1 -- offering curve25519-sha256 next to the PQ methods."
warn " Admits clients with no PQ KEX at all (Windows in-box ssh.exe), at the cost of"
warn " store-now-decrypt-later protection for any session that negotiates it."
else
KEX_NOTE='# --- Key exchange: post-quantum hybrid only ---
# Every classical-only method is rejected, which is what protects the session key
# against "store now, decrypt later". Clients with no PQ KEX cannot connect --
# re-run with SSH_ALLOW_CLASSIC_KEX=1 to also offer curve25519-sha256.'
fi
else
# No post-quantum method on this host at all. Decide on THAT, not on an empty
# list: without the opt-in the classical names are never collected, so an empty
# list here would otherwise be misreported as "no usable KEX".
[[ "${SSH_ALLOW_CLASSIC_KEX:-0}" == "1" ]] \
|| die "OpenSSH ${SSH_VER:-?} has no post-quantum KEX (needs >= 8.5 built with sntrup761). Re-run with SSH_ALLOW_CLASSIC_KEX=1 to accept classical-only."
[[ -n "$KEX_LIST" ]] \
|| die "OpenSSH ${SSH_VER:-?} reports no usable key exchange method at all ('ssh -Q kex' returned nothing)."
KEX_NOTE='# --- Key exchange: CLASSICAL ONLY ---
# This OpenSSH has no post-quantum method. No store-now-decrypt-later protection.'
warn "OpenSSH ${SSH_VER:-?} has no PQ KEX -- classical curve25519 only."
fi
log "OpenSSH ${SSH_VER:-?}: KexAlgorithms ${KEX_LIST}"
# ----------------------------------------------------------------------------
# 3. Host keys (Ed25519 only)
@@ -174,7 +202,7 @@ LogLevel VERBOSE
# --- Host key: Ed25519 only ---
HostKey /etc/ssh/ssh_host_ed25519_key
# --- Post-quantum hybrid KEX only ---
${KEX_NOTE}
KexAlgorithms ${KEX_LIST}
# --- Modern ciphers and MACs ---
+54 -21
View File
@@ -15,7 +15,9 @@
# 1. Generates fresh Ed25519 host keys; removes RSA/ECDSA/DSA host keys
# 2. Generates an Ed25519 root keypair, installs the public key into
# /root/.ssh/authorized_keys, and PRINTS the private key to stdout once.
# 3. Forces post-quantum hybrid KEX only (mlkem768x25519, sntrup761x25519).
# 3. Forces post-quantum hybrid KEX only (mlkem768x25519, sntrup761x25519 --
# every spelling this OpenSSH supports). SSH_ALLOW_CLASSIC_KEX=1 also
# offers curve25519-sha256 for clients with no PQ method at all.
# 4. Modern ciphers and MACs only.
# 5. Disables everything but an interactive terminal + SFTP (no forwarding,
# tunneling, X11, agent, password auth).
@@ -34,6 +36,16 @@
# SSH_PORT=2222 bash harden-ssh.sh # change port
# ALLOWED_IP=1.2.3.4 bash harden-ssh.sh # whitelist your client IP
# FORCE=1 bash harden-ssh.sh # skip the confirm prompt
# SSH_ALLOW_CLASSIC_KEX=1 bash harden-ssh.sh # also offer curve25519-sha256
#
# SSH_ALLOW_CLASSIC_KEX exists for one reason: clients too old for ANY
# post-quantum KEX. The ssh.exe bundled with Windows is the common case -- its
# `ssh -Q kex` lists no mlkem or sntrup at all, so it cannot connect to a
# PQ-only host however the server spells the algorithms. Turning this on is a
# real trade: a session that negotiates curve25519-sha256 is safe against a
# classical attacker but has no store-now-decrypt-later protection. Prefer
# upgrading the client (Git for Windows, WSL, or a current Win32-OpenSSH ship
# OpenSSH 9.x) and leave this off.
set -euo pipefail
@@ -67,23 +79,47 @@ if ! command -v ssh >/dev/null 2>&1; then
install_openssh || die "Could not install OpenSSH; cannot harden. Fix the package error above, then re-run."
fi
log "Checking OpenSSH version supports PQ KEX..."
SSH_VER=$(ssh -V 2>&1 | grep -oE 'OpenSSH_[0-9]+\.[0-9]+' | head -1 | sed 's/OpenSSH_//')
SSH_MAJOR=${SSH_VER%%.*}
SSH_MINOR=${SSH_VER##*.}
log "Checking which key exchange methods this OpenSSH supports..."
# Cosmetic only -- every decision below comes from `ssh -Q kex`, not this. A build
# whose banner does not match (OpenSSH_for_Windows_9.5p2, vendor forks) must not
# abort the run: without the guard, grep's non-match fails the pipeline under
# pipefail and set -e kills the script here with no message at all.
SSH_VER=$(ssh -V 2>&1 | grep -oE 'OpenSSH_[0-9]+[.][0-9]+' | head -1 | sed 's/OpenSSH_//' || true)
# OpenSSH 9.0+ has sntrup761x25519-sha512; 9.9+ adds mlkem768x25519-sha256.
HAS_MLKEM=0
HAS_SNTRUP=0
[[ $SSH_MAJOR -gt 9 || ( $SSH_MAJOR -eq 9 && $SSH_MINOR -ge 0 ) ]] && HAS_SNTRUP=1
[[ $SSH_MAJOR -gt 9 || ( $SSH_MAJOR -eq 9 && $SSH_MINOR -ge 9 ) ]] && HAS_MLKEM=1
[[ $HAS_SNTRUP -eq 1 || $HAS_MLKEM -eq 1 ]] \
|| die "OpenSSH ${SSH_VER} has no PQ KEX. Need >= 9.0. Upgrade the base OS first."
log "OpenSSH ${SSH_VER}: ML-KEM=${HAS_MLKEM} sntrup761=${HAS_SNTRUP}"
# Ask the binary what it supports rather than deriving it from the version -- the
# same algorithm has two spellings and guessing wrong either breaks sshd_config or
# locks out clients that implement it under the other name. See oslib.
KEX_PQ="$(ssh_kex_pq_list)"
KEX_LIST="$(ssh_kex_list)"
KEX_LIST=""
[[ $HAS_MLKEM -eq 1 ]] && KEX_LIST="mlkem768x25519-sha256"
[[ $HAS_SNTRUP -eq 1 ]] && KEX_LIST="${KEX_LIST:+$KEX_LIST,}sntrup761x25519-sha512"
if [[ -n "$KEX_PQ" ]]; then
if [[ "${SSH_ALLOW_CLASSIC_KEX:-0}" == "1" ]]; then
KEX_NOTE='# --- Key exchange: post-quantum hybrid + classical fallback ---
# curve25519-sha256 is offered for clients too old for any PQ method
# (SSH_ALLOW_CLASSIC_KEX=1). A session that negotiates it has NO store-now-
# decrypt-later protection -- drop the fallback once those clients are gone.'
warn "SSH_ALLOW_CLASSIC_KEX=1 -- offering curve25519-sha256 next to the PQ methods."
warn " Admits clients with no PQ KEX at all (Windows in-box ssh.exe), at the cost of"
warn " store-now-decrypt-later protection for any session that negotiates it."
else
KEX_NOTE='# --- Key exchange: post-quantum hybrid only ---
# Every classical-only method is rejected, which is what protects the session key
# against "store now, decrypt later". Clients with no PQ KEX cannot connect --
# re-run with SSH_ALLOW_CLASSIC_KEX=1 to also offer curve25519-sha256.'
fi
else
# No post-quantum method on this host at all. Decide on THAT, not on an empty
# list: without the opt-in the classical names are never collected, so an empty
# list here would otherwise be misreported as "no usable KEX".
[[ "${SSH_ALLOW_CLASSIC_KEX:-0}" == "1" ]] \
|| die "OpenSSH ${SSH_VER:-?} has no post-quantum KEX (needs >= 8.5 built with sntrup761). Re-run with SSH_ALLOW_CLASSIC_KEX=1 to accept classical-only."
[[ -n "$KEX_LIST" ]] \
|| die "OpenSSH ${SSH_VER:-?} reports no usable key exchange method at all ('ssh -Q kex' returned nothing)."
KEX_NOTE='# --- Key exchange: CLASSICAL ONLY ---
# This OpenSSH has no post-quantum method. No store-now-decrypt-later protection.'
warn "OpenSSH ${SSH_VER:-?} has no PQ KEX -- classical curve25519 only."
fi
log "OpenSSH ${SSH_VER:-?}: KexAlgorithms ${KEX_LIST}"
# ----------------------------------------------------------------------------
# 2. Install packages (OS-gated inside oslib)
@@ -175,9 +211,7 @@ PidFile /run/sshd.pid
# --- Host key: Ed25519 only ---
HostKey /etc/ssh/ssh_host_ed25519_key
# --- Post-quantum hybrid KEX only ---
# Anything not in this list (every classical-only KEX) is rejected, which is
# what protects the session key against "store now, decrypt later".
${KEX_NOTE}
KexAlgorithms ${KEX_LIST}
# --- Modern ciphers and MACs ---
@@ -391,8 +425,7 @@ terminal -- before answering yes -- to verify the new keys, port, and PQ
KEX work. If something is wrong, this reload will end your current session.
Test in another terminal first:
ssh -i ~/.ssh/<your saved key> -p ${SSH_PORT} \\
-o KexAlgorithms=${KEX_LIST} root@<host>
ssh -i ~/.ssh/<your saved key> -p ${SSH_PORT} root@<host>
Reload sshd now? [y/N]
EOF
+60
View File
@@ -172,6 +172,66 @@ sshd_service() {
[[ "$OS_FAMILY" == debian ]] && echo ssh || echo sshd
}
# ============================================================================
# SSH key exchange -- post-quantum hybrid, with an opt-in classical fallback.
# ============================================================================
# Build the KexAlgorithms list from what THIS OpenSSH build actually supports,
# asked via `ssh -Q kex`, instead of inferring it from a version number. The
# same algorithm has two spellings -- OpenSSH used
# sntrup761x25519-sha512@openssh.com before the method was standardised and
# sntrup761x25519-sha512 after -- and guessing wrong breaks in both directions:
# a name the local sshd does not know is a fatal sshd_config error, while a name
# the CLIENT does not know is an "Unable to negotiate ... no matching key
# exchange method found" lockout even though both ends implement the algorithm.
# Offering every spelling this host supports costs nothing and avoids both.
#
# SSH_ALLOW_CLASSIC_KEX=1 additionally offers curve25519-sha256 (and its older
# @libssh.org spelling): ordinary X25519 ECDH, secure against a classical
# attacker but with NO post-quantum protection. It exists for clients too old
# for any PQ method -- notably the ssh.exe bundled with Windows, which has none
# -- and the callers warn when it is on.
kex_supported() { # kex_supported <algorithm-name>
ssh -Q kex 2>/dev/null | grep -qxF "$1"
}
# The post-quantum hybrids this host supports, every spelling it has. Empty when
# this OpenSSH has none -- pre-8.5, or a build with sntrup761 compiled out (the
# Microsoft fork does exactly that: it needs C99 VLAs, which MSVC lacks).
ssh_kex_pq_list() {
local list="" k
for k in mlkem768x25519-sha256 \
sntrup761x25519-sha512 \
sntrup761x25519-sha512@openssh.com; do
if kex_supported "$k"; then list="${list:+$list,}$k"; fi
done
printf '%s\n' "$list"
}
# The classical fallback. Offered only when SSH_ALLOW_CLASSIC_KEX=1.
ssh_kex_classic_list() {
local list="" k
for k in curve25519-sha256 curve25519-sha256@libssh.org; do
if kex_supported "$k"; then list="${list:+$list,}$k"; fi
done
printf '%s\n' "$list"
}
# The KexAlgorithms value itself: PQ first, classical appended only on request.
ssh_kex_list() {
local pq classic=""
pq="$(ssh_kex_pq_list)"
if [[ "${SSH_ALLOW_CLASSIC_KEX:-0}" == "1" ]]; then classic="$(ssh_kex_classic_list)"; fi
if [[ -n "$pq" && -n "$classic" ]]; then
printf '%s,%s\n' "$pq" "$classic"
else
printf '%s\n' "${pq}${classic}"
fi
}
ssh_kex_has_pq() { # ssh_kex_has_pq <list>
case "$1" in *mlkem*|*sntrup*) return 0 ;; *) return 1 ;; esac
}
# ============================================================================
# SSH-specific paths
# ============================================================================