#!/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 "$(user_group)" "$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}:$(user_group)" "$dir" # Returns 0 whatever happens. This is an optional improvement, and a # function that ends on a failing command is fatal under `set -e` when it # is called as a plain command — which would abort the remaining sections # over something the run could simply report. The caller checks the outcome. return 0 } # 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 "$(user_group)" "$(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 "$(user_group)" "$(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 }