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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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