diff --git a/scripts/setup/machine-setup/lib/ssh.sh b/scripts/setup/machine-setup/lib/ssh.sh new file mode 100644 index 00000000..d65c6004 --- /dev/null +++ b/scripts/setup/machine-setup/lib/ssh.sh @@ -0,0 +1,158 @@ +#!/bin/bash +# ============================================================================= +# machine-setup — ssh keys and ssh hardening +# ============================================================================= +# +# Definitions only, like the other lib/ files. +# +# ── Why the original's hardening did not work, and could not be seen not to ── +# +# It sed'd /etc/ssh/sshd_config directly. Two things make that wrong on a modern +# Ubuntu, and both fail silently: +# +# Ubuntu's sshd_config has `Include /etc/ssh/sshd_config.d/*.conf` on line 12, +# and sshd takes the FIRST value it obtains for a keyword — not the last. Cloud +# images ship 50-cloud-init.conf containing `PasswordAuthentication yes`, which +# is read before anything further down the main file. So the sed edits a line +# sshd never reaches, the script reports "SSH hardened", and password login is +# still on. +# +# It also sed'd ChallengeResponseAuthentication, which OpenSSH renamed to +# KbdInteractiveAuthentication in 8.7. On 24.04 the old name appears nowhere in +# the file, so that substitution matched nothing at all. +# +# So the settings go in a drop-in named to sort FIRST — 01- beats 50-cloud-init — +# which is the only placement that actually wins under first-value-wins. +# +# ── And the reason it is dangerous ── +# +# Step 8 of the original could warn-and-skip (no ssh-keys.zip, or an unrecognised +# menu choice, since its case had no default arm) and still mark itself done. +# Step 9 then disabled password authentication and root login regardless. No key, +# no password, no root: locked out at the next disconnect, on a machine that may +# be in a datacentre. Nothing here disables password authentication without first +# confirming a usable key is in place. + +[[ -n "${MACHINE_SETUP_SSH_LOADED:-}" ]] && return 0 +MACHINE_SETUP_SSH_LOADED=1 + +SSHD_DROPIN=/etc/ssh/sshd_config.d/01-machine-setup.conf + +# ----------------------------------------------------------------------------- +# Keys +# ----------------------------------------------------------------------------- + +user_ssh_dir() { echo "${USER_HOME}/.ssh"; } +user_authorized_keys() { echo "${USER_HOME}/.ssh/authorized_keys"; } + +# How many usable keys the account can log in with. +# +# Counted by asking ssh-keygen to parse the file rather than by counting lines: +# comments, blanks and a half-pasted key all look like lines, and "there is a +# file" is not the same fact as "there is a key that works". +authorized_key_count() { + local file + file="$(user_authorized_keys)" + [[ -r "$file" ]] || return 0 + ssh-keygen -l -f "$file" 2>/dev/null | grep -c . || true +} + +has_authorized_key() { (($(authorized_key_count) > 0)); } + +# Everything about ~/.ssh that has to be true for sshd to use it at all. sshd +# ignores an authorized_keys file that is group- or world-writable, and does so +# silently from the client's point of view — the login just fails. +fix_ssh_permissions() { + local dir + dir="$(user_ssh_dir)" + [[ -d "$dir" ]] || install -d -m 0700 -o "$USERNAME" -g "$USERNAME" "$dir" + chmod 700 "$dir" + [[ -f "$dir/authorized_keys" ]] && chmod 600 "$dir/authorized_keys" + find "$dir" -maxdepth 1 -type f -name 'id_*' ! -name '*.pub' -exec chmod 600 {} + + chown -R "$USERNAME:$USERNAME" "$dir" +} + +# Add a public key, once. Appending blindly is how authorized_keys ends up with +# the same key four times after four runs. +add_authorized_key() { + local key="$1" file + file="$(user_authorized_keys)" + + # Validated before it is stored. A truncated paste or a private key pasted by + # mistake would otherwise sit there looking like a key and never work. + if ! ssh-keygen -l -f /dev/stdin <<<"$key" >/dev/null 2>&1; then + warn "that does not parse as an ssh public key — nothing added" + return 1 + fi + + install -d -m 0700 -o "$USERNAME" -g "$USERNAME" "$(user_ssh_dir)" + touch "$file" + + # Compare on the key body, not the whole line: the trailing comment differs + # between machines and is not part of the identity. + local body + body="$(awk '{print $2}' <<<"$key")" + if [[ -n "$body" ]] && grep -qF "$body" "$file" 2>/dev/null; then + info " that key is already authorised" + return 0 + fi + + printf '%s\n' "$key" >>"$file" + fix_ssh_permissions +} + +# Generate a keypair for the account and authorise it. +generate_user_key() { + local comment="$1" key + key="$(user_ssh_dir)/id_ed25519" + + install -d -m 0700 -o "$USERNAME" -g "$USERNAME" "$(user_ssh_dir)" + sudo -u "$USERNAME" ssh-keygen -t ed25519 -C "$comment" -f "$key" -N "" >/dev/null + add_authorized_key "$(cat "${key}.pub")" +} + +# ----------------------------------------------------------------------------- +# Hardening +# ----------------------------------------------------------------------------- + +# What sshd actually resolves a setting to, across the main file and every +# drop-in. The only honest way to report the current state: reading the config +# files tells you what is written, not what wins. +sshd_effective() { sshd -T 2>/dev/null | awk -v k="${1,,}" 'tolower($1) == k { print $2; exit }'; } + +# Write the drop-in, verify it, and only then reload. +# +# Returns non-zero without touching the running daemon if the result would not +# parse — the alternative is a config that sshd refuses, at which point it will +# not come back after a restart and the machine has no ssh at all. +harden_sshd() { + local backup="" + + [[ -f "$SSHD_DROPIN" ]] && backup="$(mktemp)" && cp "$SSHD_DROPIN" "$backup" + + install -d -m 0755 /etc/ssh/sshd_config.d + cat >"$SSHD_DROPIN" <<'EOF' +# Written by machine-setup. +# +# Named 01- deliberately: sshd uses the FIRST value it obtains for a keyword, and +# Ubuntu includes this directory from the top of sshd_config. A file sorting +# after 50-cloud-init.conf would be read too late to override it. +PasswordAuthentication no +KbdInteractiveAuthentication no +PermitRootLogin no +PubkeyAuthentication yes +EOF + chmod 644 "$SSHD_DROPIN" + + if ! sshd -t 2>/dev/null; then + warn "sshd rejected the new configuration — reverting, nothing changed" + if [[ -n "$backup" ]]; then cp "$backup" "$SSHD_DROPIN"; else rm -f "$SSHD_DROPIN"; fi + [[ -n "$backup" ]] && rm -f "$backup" + return 1 + fi + [[ -n "$backup" ]] && rm -f "$backup" + + # Reload rather than restart: existing sessions keep their sshd, so the + # connection this is being run over is not the thing being experimented on. + systemctl reload ssh 2>/dev/null || systemctl reload sshd 2>/dev/null || systemctl restart ssh +} diff --git a/scripts/setup/machine-setup/machine-setup.sh b/scripts/setup/machine-setup/machine-setup.sh index 36773366..b43a29ca 100755 --- a/scripts/setup/machine-setup/machine-setup.sh +++ b/scripts/setup/machine-setup/machine-setup.sh @@ -28,6 +28,8 @@ source "$SCRIPT_DIR/lib/system.sh" source "$SCRIPT_DIR/lib/disk.sh" # shellcheck source=lib/files.sh source "$SCRIPT_DIR/lib/files.sh" +# shellcheck source=lib/ssh.sh +source "$SCRIPT_DIR/lib/ssh.sh" # Trap errors with context. Installed here rather than in lib/base.sh, because # that file is definitions only and a trap is a side effect on whoever sources it. @@ -828,13 +830,108 @@ elif ! skip; then step_ok fi +# ============================================================================= +# 15. SSH access +# ============================================================================= +# +# Keys and hardening in one section, deliberately. They were two in the original, +# and being two is what let the second one lock you out of a machine the first +# one had failed to put a key on. + +step "SSH access" +if ! skip; then + KEY_COUNT="$(authorized_key_count)" + + echo "" + info "SSH access — how you get into this machine" + echo " authorised keys for ${USERNAME}: ${KEY_COUNT}" + echo " password login: $(sshd_effective passwordauthentication)" + echo " root login: $(sshd_effective permitrootlogin)" + + # ── a key first ── + if ((KEY_COUNT == 0)); then + echo "" + warn "${USERNAME} has no authorised key. Password login cannot be turned off until it has one." + echo " [1] paste a public key (the contents of your ~/.ssh/id_ed25519.pub)" + echo " [2] generate a new keypair on this machine" + echo " [3] leave it for now" + echo "" + + SSH_KEY_DONE=false + while [[ "$SSH_KEY_DONE" == false ]]; do + if ! read -rp " Which one? (1/2/3) [1]: " SSH_KEY_CHOICE; then + echo "" + fail "No answer." + fi + case "${SSH_KEY_CHOICE:-1}" in + 1) + read -rp " Paste the public key: " SSH_PASTED || fail "No answer." + add_authorized_key "$SSH_PASTED" && SSH_KEY_DONE=true + ;; + 2) + generate_user_key "${USERNAME}@$(hostname)" + ok "keypair generated and authorised" + echo "" + echo " The PRIVATE key is on this machine at ${USER_HOME}/.ssh/id_ed25519." + echo " Copy it to the machine you connect FROM, then delete it here —" + echo " a private key that lives on the server it opens is not a" + echo " second factor, it is a spare copy of the lock." + SSH_KEY_DONE=true + ;; + 3) SSH_KEY_DONE=true ;; + *) warn "Pick 1, 2 or 3." ;; + esac + done + KEY_COUNT="$(authorized_key_count)" + else + fix_ssh_permissions + fi + + # ── then hardening, and only then ── + if [[ "$(sshd_effective passwordauthentication)" == "no" && "$(sshd_effective permitrootlogin)" == "no" ]]; then + echo "" + echo " already hardened, nothing to do" + SUMMARY+=("SSH: already hardened, ${KEY_COUNT} key(s) for ${USERNAME}") + elif ((KEY_COUNT == 0)); then + echo "" + warn "not hardening: ${USERNAME} still has no authorised key" + echo " Turning off password login now would leave no way in at all. Add a" + echo " key and run this again." + SUMMARY+=("SSH: NOT hardened — no key for ${USERNAME}, password login left on") + else + echo "" + info "Harden sshd?" + echo " Turns off password login, keyboard-interactive login and root" + echo " login. ${USERNAME} has ${KEY_COUNT} authorised key(s), so you keep a way in." + echo "" + echo " Written to ${SSHD_DROPIN} rather than sshd_config, and named 01- so" + echo " it is read before the cloud-init drop-in that would otherwise win." + echo " Checked with 'sshd -t' before anything is reloaded, and reloaded" + echo " rather than restarted so this session is not the experiment." + echo "" + warn "Test a new ssh session before closing this one." + if confirm "Proceed?"; then + if harden_sshd; then + ok "password login: $(sshd_effective passwordauthentication), root login: $(sshd_effective permitrootlogin)" + SUMMARY+=("SSH: hardened (password and root login off, ${KEY_COUNT} key(s))") + else + SUMMARY+=("SSH: hardening FAILED — sshd rejected the config, nothing changed") + fi + else + warn "skipped by request" + SUMMARY+=("SSH: SKIPPED by request — password login left on") + fi + fi + step_ok +fi + # ============================================================================= # NOT PORTED YET # ============================================================================= # # Sections still to move across from scripts/setup-old/setup-ubuntu.sh, in order: # -# ssh keys · ssh hardening · dns · static ip · fail2ban · unattended-upgrades · +# dns · static ip · fail2ban · unattended-upgrades · # git config · docker · zsh + prompt (incl. .tmux.conf) · tailscale · neovim · js runtimes · # dev tools · ufw · zshrc #