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
+158
View File
@@ -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
}
+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
#