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:
@@ -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
|
||||
# ============================================================================
|
||||
|
||||
Reference in New Issue
Block a user