#!/usr/bin/env bash # # harden-ssh.sh # # SSH hardening for Alpine Linux. Run BEFORE deploy-simplex.sh on a fresh box. # # What this does: # 1. Generates fresh Ed25519 host keys; removes RSA/ECDSA/DSA host keys # 2. Generates an Ed25519 root keypair on the host, installs the public key # into /root/.ssh/authorized_keys, and PRINTS the private key to stdout # so you can copy it to your client. THIS IS YOUR ONLY CHANCE TO COPY IT. # 3. Forces post-quantum hybrid KEX only: # mlkem768x25519-sha256 (the future default, NIST ML-KEM hybrid) # sntrup761x25519-sha512 (older PQ KEX, kept as fallback) # Drops every classical-only KEX. Connections that don't speak PQ KEX # will be rejected. # 4. Modern ciphers and MACs only (chacha20-poly1305, aes256-gcm, # hmac-sha2-512-etm) # 5. Disables everything not needed for an interactive terminal: # - password auth, root password login (key-only) # - challenge-response, GSSAPI, PAM, host-based auth # - X11 forwarding, agent forwarding # - TCP forwarding, stream-local forwarding (UNIX sockets) # - tunneling (PermitTunnel), gateway ports # - SFTP subsystem (kept ON — needed for backup retrieval) # - empty passwords, .ssh/rc execution, compression # Result: a session can run a shell. That's it. No -L, no -R, no -D, no # jump hosting, no sftp, no scp. # 6. Optional non-default port (-p PORT) # 7. Installs sshguard with iptables backend for brute-force protection # 8. Validates config with `sshd -t` and prompts for confirmation before # reloading sshd (so a config error or a typo doesn't lock you out) # # A note on "quantum-safe": # Stock OpenSSH provides PQ KEY EXCHANGE (the session key, the thing that # matters for "store now, decrypt later"). It does NOT yet provide PQ # AUTHENTICATION KEYS -- there is no standardized PQ host or user key # algorithm in mainline OpenSSH yet. So: # - Your session is PQ-protected against SNDL: yes # - Your auth keypair (Ed25519) is classical: yes, and that's the best # practical choice today. PQ signature support exists only in the # open-quantum-safe/openssh fork, which breaks compatibility with # every standard SSH client. # This script gives you the strongest stock-OpenSSH posture available. # # Usage: # bash harden-ssh.sh # port stays 22, default # SSH_PORT=2222 bash harden-ssh.sh # change port # ALLOWED_IP=1.2.3.4 bash harden-ssh.sh # whitelist your client IP in sshguard # FORCE=1 bash harden-ssh.sh # skip the "are you sure" prompt set -euo pipefail # ============================================================================ # CONFIG # ============================================================================ : "${SSH_PORT:=22}" : "${ALLOWED_IP:=}" # optional: your client IP, will be sshguard-whitelisted : "${KEY_COMMENT:=root@$(hostname)-$(date +%Y%m%d)}" : "${FORCE:=0}" log() { printf '\033[1;32m[+]\033[0m %s\n' "$*"; } warn() { printf '\033[1;33m[!]\033[0m %s\n' "$*" >&2; } die() { printf '\033[1;31m[x]\033[0m %s\n' "$*" >&2; exit 1; } [[ $EUID -eq 0 ]] || die "Run as root." [[ -f /etc/alpine-release ]] || die "This script targets Alpine Linux." # ---------------------------------------------------------------------------- # 1. Pre-flight checks # ---------------------------------------------------------------------------- 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##*.} # OpenSSH 9.0+ has sntrup761x25519-sha512. # OpenSSH 9.9+ also has mlkem768x25519-sha256. HAS_MLKEM=0 HAS_SNTRUP=0 if [[ $SSH_MAJOR -gt 9 || ( $SSH_MAJOR -eq 9 && $SSH_MINOR -ge 0 ) ]]; then HAS_SNTRUP=1 fi if [[ $SSH_MAJOR -gt 9 || ( $SSH_MAJOR -eq 9 && $SSH_MINOR -ge 9 ) ]]; then HAS_MLKEM=1 fi [[ $HAS_SNTRUP -eq 1 || $HAS_MLKEM -eq 1 ]] \ || die "OpenSSH ${SSH_VER} has no PQ KEX. Need >= 9.0. Upgrade Alpine first." log "OpenSSH ${SSH_VER}: ML-KEM=${HAS_MLKEM} sntrup761=${HAS_SNTRUP}" # Build the KEX list from what's actually available. KEX_LIST="" [[ $HAS_MLKEM -eq 1 ]] && KEX_LIST="mlkem768x25519-sha256" [[ $HAS_SNTRUP -eq 1 ]] && KEX_LIST="${KEX_LIST:+$KEX_LIST,}sntrup761x25519-sha512" # ---------------------------------------------------------------------------- # 2. Install packages # ---------------------------------------------------------------------------- log "Installing openssh-server-pam, sshguard, iptables..." # openssh-server-pam replaces openssh-server (PAM-enabled sshd). If the # non-pam version was installed earlier, swap it out cleanly. if apk info -e openssh-server >/dev/null 2>&1 && \ ! apk info -e openssh-server-pam >/dev/null 2>&1; then apk del -q openssh-server || true fi apk add -q openssh openssh-server-pam linux-pam sshguard iptables ip6tables openrc # ---------------------------------------------------------------------------- # 3. Host keys -- regenerate with Ed25519 only # ---------------------------------------------------------------------------- log "Regenerating host keys (Ed25519 only)..." rm -f /etc/ssh/ssh_host_rsa_key* \ /etc/ssh/ssh_host_ecdsa_key* \ /etc/ssh/ssh_host_dsa_key* # Keep existing ed25519 key if there is one (so the host fingerprint doesn't # change unnecessarily on re-runs). Generate one if not. if [[ ! -f /etc/ssh/ssh_host_ed25519_key ]]; then ssh-keygen -q -t ed25519 -f /etc/ssh/ssh_host_ed25519_key -N "" \ -C "host@$(hostname)-$(date +%Y%m%d)" fi chmod 600 /etc/ssh/ssh_host_ed25519_key chmod 644 /etc/ssh/ssh_host_ed25519_key.pub log "Host key fingerprint (verify on first connect):" ssh-keygen -l -f /etc/ssh/ssh_host_ed25519_key.pub | sed 's/^/ /' # Stop Alpine's sshd init from regenerating RSA/ECDSA keys on every start. # /etc/conf.d/sshd: pin sshd_disable_keygen=no but only generate ed25519 by # overriding the keygen line in the init script via a drop-in. if [[ -f /etc/conf.d/sshd ]]; then if ! grep -q '^sshd_disable_keygen=' /etc/conf.d/sshd; then echo 'sshd_disable_keygen="yes"' >> /etc/conf.d/sshd else sed -i 's/^sshd_disable_keygen=.*/sshd_disable_keygen="yes"/' /etc/conf.d/sshd fi fi # ---------------------------------------------------------------------------- # 4. Root user keypair # ---------------------------------------------------------------------------- log "Generating Ed25519 keypair for root..." mkdir -p /root/.ssh chmod 700 /root/.ssh touch /root/.ssh/authorized_keys chmod 600 /root/.ssh/authorized_keys # Always create a brand-new key pair in a temp location so we can show the # private key to the user and then add the public key to authorized_keys. TMP_KEY=$(mktemp -u /tmp/root_ed25519.XXXXXX) ssh-keygen -q -t ed25519 -f "$TMP_KEY" -N "" -C "$KEY_COMMENT" ROOT_PUB=$(cat "${TMP_KEY}.pub") ROOT_PRIV=$(cat "$TMP_KEY") # Idempotency: don't add the same pubkey twice. if ! grep -qxF "$ROOT_PUB" /root/.ssh/authorized_keys; then echo "$ROOT_PUB" >> /root/.ssh/authorized_keys fi # ---------------------------------------------------------------------------- # 5. sshd_config # ---------------------------------------------------------------------------- log "Writing /etc/ssh/sshd_config..." # Back up whatever was there before, once. [[ -f /etc/ssh/sshd_config.orig ]] || cp /etc/ssh/sshd_config /etc/ssh/sshd_config.orig cat > /etc/ssh/sshd_config </tmp/sshd-test.err; then cat /tmp/sshd-test.err >&2 cp /etc/ssh/sshd_config.orig /etc/ssh/sshd_config die "sshd config invalid; restored original. NOT reloading." fi rm -f /tmp/sshd-test.err # ---------------------------------------------------------------------------- # 7. sshguard (brute-force protection) # ---------------------------------------------------------------------------- log "Configuring sshguard with iptables backend..." mkdir -p /etc/sshguard # Whitelist: localhost always, plus optional caller-supplied IP. WHITELIST=/etc/sshguard/whitelist { echo "127.0.0.1" echo "::1" [[ -n "$ALLOWED_IP" ]] && echo "$ALLOWED_IP" } > "$WHITELIST" # sshguard.conf: explicitly point it at iptables backend. cat > /etc/sshguard/sshguard.conf < /etc/local.d/sshguard-iptables.start <<'EOF' #!/bin/sh # Ensure sshguard chain exists and INPUT jumps to it for tcp/22 (and PORT). SSH_PORT=$(awk '/^Port / {print $2; exit}' /etc/ssh/sshd_config) SSH_PORT=${SSH_PORT:-22} for ipt in iptables ip6tables; do $ipt -N sshguard 2>/dev/null || true $ipt -C INPUT -p tcp --dport "$SSH_PORT" -j sshguard 2>/dev/null \ || $ipt -I INPUT -p tcp --dport "$SSH_PORT" -j sshguard done EOF chmod +x /etc/local.d/sshguard-iptables.start rc-update add local default 2>/dev/null || true /etc/local.d/sshguard-iptables.start rc-update add sshguard default rc-service sshguard restart || rc-service sshguard start # ---------------------------------------------------------------------------- # 8. SSHD enable & reload (with safety prompt) # ---------------------------------------------------------------------------- log "Enabling sshd at boot..." rc-update add sshd default # Print the private key BEFORE reloading sshd so even if reload locks the # user out, they have what they need to come back in via console. cat < ----- BEGIN ROOT PRIVATE KEY (Ed25519) ----- ${ROOT_PRIV} ----- END ROOT PRIVATE KEY ----- Public key (already in /root/.ssh/authorized_keys): ${ROOT_PUB} Host fingerprint (verify on first connect): $(ssh-keygen -l -f /etc/ssh/ssh_host_ed25519_key.pub) ================================================================ EOF # Wipe the temp files holding the private key. shred -u "$TMP_KEY" "${TMP_KEY}.pub" 2>/dev/null || rm -f "$TMP_KEY" "${TMP_KEY}.pub" # Final guard: confirm before reloading sshd. A bad reload is recoverable from # console; a bad reload while you assumed everything was fine is not. if [[ "$FORCE" != "1" ]]; then cat < -p ${SSH_PORT} \\ -o KexAlgorithms=${KEX_LIST} root@ Reload sshd now? [y/N] EOF read -r ans if [[ "${ans,,}" != "y" && "${ans,,}" != "yes" ]]; then warn "Skipping reload. Run 'rc-service sshd reload' manually when ready." exit 0 fi fi log "Reloading sshd..." rc-service sshd reload || rc-service sshd restart log "Done. Your session, if any, should remain alive (reload preserves connections)." log "Test from another machine before closing this session."