port ssh keys and hardening as one section, and make the hardening actually work

They were two sections, and being two is what let the second lock you out of a
machine the first had failed to put a key on. Step 8 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, on a box that may be in a
datacentre.

Nothing here turns off password authentication without first confirming a usable
key is in place, and the refusal says why rather than skipping quietly.

The hardening also did not do anything on a modern Ubuntu, and could not be seen
not to:

  It sed'd /etc/ssh/sshd_config. Ubuntu includes /etc/ssh/sshd_config.d/*.conf
  from line 12 of that file, and sshd takes the FIRST value it obtains for a
  keyword rather than the last. Cloud images ship 50-cloud-init.conf containing
  `PasswordAuthentication yes`, read long before the line the sed edited. The run
  reported "SSH hardened" and password login stayed on. The settings now go in a
  drop-in named 01-machine-setup.conf, which is the only placement that wins
  under first-value-wins.

  It also sed'd ChallengeResponseAuthentication, renamed to
  KbdInteractiveAuthentication in OpenSSH 8.7. On 24.04 the old name is nowhere
  in the file, so that substitution matched nothing at all.

State is read with `sshd -T`, which reports what sshd resolves across the main
file and every drop-in — reading the config files tells you what is written, not
what wins.

Keys are counted by asking ssh-keygen to parse authorized_keys rather than by
counting lines: comments, blanks and a half-finished paste all look like lines,
and "there is a file" is not "there is a key that works". A pasted key is
validated before it is stored, and matched on the key body rather than the whole
line, so re-running does not authorise the same key four times over four runs.

sshd -t validates the new config before anything is reloaded, and the drop-in is
restored or removed if it does not parse — a config sshd refuses is a machine
with no ssh after the next restart. Reload rather than restart, so the session
this is running over is not the experiment, and the run says out loud to test a
new connection before closing the current one.

Generating a keypair now says the obvious thing the original did not: the private
key is on the server, and a private key living on the machine it opens is a spare
copy of the lock rather than a second factor.

Verified against this host (1 key, already hardened, correctly does nothing) and
with sshd_effective stubbed to a fresh-cloud-image state — the guard refuses and
harden_sshd is never reached. Also verified key validation, dedup and 0700/0600
permissions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-12 18:39:23 +00:00
co-authored by Claude Opus 5
parent b1cc916258
commit 9591f917f5
2 changed files with 256 additions and 1 deletions
+98 -1
View File
@@ -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
#