diff --git a/README.md b/README.md index c16c16b..2673afd 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,7 @@ deployments// # 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 diff --git a/cloud-init/base.yml b/cloud-init/base.yml index 1ce5c02..6813d1d 100644 --- a/cloud-init/base.yml +++ b/cloud-init/base.yml @@ -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 diff --git a/cloud-init/jumphost.yml b/cloud-init/jumphost.yml index 681d647..8967ac6 100644 --- a/cloud-init/jumphost.yml +++ b/cloud-init/jumphost.yml @@ -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 diff --git a/scripts/harden-jumphost.sh b/scripts/harden-jumphost.sh index be031b2..1cdbdca 100644 --- a/scripts/harden-jumphost.sh +++ b/scripts/harden-jumphost.sh @@ -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 --- diff --git a/scripts/harden-ssh.sh b/scripts/harden-ssh.sh index 406f55d..6201f95 100644 --- a/scripts/harden-ssh.sh +++ b/scripts/harden-ssh.sh @@ -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/ -p ${SSH_PORT} \\ - -o KexAlgorithms=${KEX_LIST} root@ + ssh -i ~/.ssh/ -p ${SSH_PORT} root@ Reload sshd now? [y/N] EOF diff --git a/scripts/oslib.sh b/scripts/oslib.sh index ae35af6..e2f0177 100644 --- a/scripts/oslib.sh +++ b/scripts/oslib.sh @@ -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 + 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 + case "$1" in *mlkem*|*sntrup*) return 0 ;; *) return 1 ;; esac +} + # ============================================================================ # SSH-specific paths # ============================================================================