diff --git a/migrations/1788124236.sh b/migrations/1788124236.sh new file mode 100644 index 00000000..0282b775 --- /dev/null +++ b/migrations/1788124236.sh @@ -0,0 +1,116 @@ +echo "Disable SSH password authentication on existing key-based setups" + +config=/etc/ssh/sshd_config.d/10-omarchy-hardening.conf +authorized_keys="$HOME/.ssh/authorized_keys" + +as_root() { + if (( EUID == 0 )); then + "$@" + else + sudo "$@" + fi +} + +# Passwords staying enabled is the state the machine has been living with, so a +# condition this migration cannot repair completes with a notice instead of +# failing and holding up every migration queued behind it. Only missing +# privileges stay pending below, because rerunning from a terminal fixes that. +skip() { + echo "$1 SSH password authentication remains enabled; run omarchy-setup-security-sshd to harden manually." + exit 0 +} + +# The fixed setup command writes this file itself. Its presence is also the +# machine-wide completion state, so migrations run by another account no-op. +if [[ -e $config || -L $config ]]; then + exit 0 +fi + +# Earlier versions enabled sshd before importing the key, but did not leave a +# marker saying that Omarchy configured it. Limit the repair to a daemon that is +# enabled or currently exposed and a user who already has a usable authorized +# key. A machine that never set SSH up exits without prompting for privileges. +if ! systemctl is-enabled --quiet sshd.service 2>/dev/null && + ! systemctl is-active --quiet sshd.service 2>/dev/null; then + exit 0 +fi + +# sshd reads authorized_keys one entry per line, while ssh-keygen -lf +# fingerprints whole files in formats sshd does not accept there — a private +# key copied in by mistake passes the file-level check even though sshd finds +# no usable entry in it. Ask sshd's question instead: does any single line +# parse as a public key? +has_usable_key() { + local line + while IFS= read -r line || [[ -n $line ]]; do + if [[ $line =~ ^[[:space:]]*(#|$) ]]; then + continue + fi + if ssh-keygen -lf /dev/stdin <<<"$line" >/dev/null 2>&1; then + return 0 + fi + done <"$authorized_keys" + return 1 +} + +if [[ ! -f $authorized_keys || -L $authorized_keys || ! -s $authorized_keys || ! -r $authorized_keys ]] || + ! has_usable_key; then + skip "$authorized_keys has no usable public key." +fi + +# Under StrictModes, sshd's default, a group- or world-writable home directory, +# ~/.ssh, or authorized_keys makes sshd ignore the key that just validated, and +# passwords would then be the only way in. Tighten the two paths the setup +# command owns, exactly as it does; the home directory is not ours to change. +home_mode=$(stat -c '%a' "$HOME" 2>/dev/null) || skip "Could not inspect the permissions on $HOME." +if (( 8#$home_mode & 8#022 )); then + skip "$HOME is group- or world-writable, so sshd would ignore the authorized key." +fi +if ! chmod 700 "$HOME/.ssh" || ! chmod 600 "$authorized_keys"; then + skip "Could not tighten the permissions on $authorized_keys." +fi + +echo "Disabling SSH password authentication on the existing key-based SSH setup..." +if ! as_root install -Dm644 /dev/stdin "$config" <<'CONF' +# Written by Omarchy once an SSH key was already authorized. +# Delete this file and reload sshd to allow password logins again. +PasswordAuthentication no +KbdInteractiveAuthentication no +CONF +then + echo "Administrator privileges are required to harden the existing SSH setup. Run omarchy-migrate again from a terminal." >&2 + exit 1 +fi + +# The drop-in itself is always valid, so a rejection means the configuration +# was already broken before it arrived — the administrator's to repair. +if ! as_root sshd -t; then + as_root rm -f -- "$config" || true + skip "sshd rejected its configuration." +fi + +effective_config=$(as_root sshd -T) || { + as_root rm -f -- "$config" || true + skip "Could not inspect sshd's effective configuration." +} + +# Syntax alone is insufficient because sshd uses the first value it reads. An +# sshd_config predating the packaged sshd_config.d Include never reads the +# drop-in at all, and an earlier administrator rule overrides it. Either way +# the file is ineffective: remove it rather than claiming the machine is +# protected. +if ! grep -qixF "passwordauthentication no" <<<"$effective_config" || + ! grep -qixF "kbdinteractiveauthentication no" <<<"$effective_config"; then + as_root rm -f -- "$config" || true + skip "sshd does not apply the hardening drop-in, so an earlier rule or a config without the sshd_config.d include wins." +fi + +# An enabled but deliberately stopped daemon picks the file up on its next +# start. Reload only a daemon that is currently serving connections so existing +# sessions survive while new ones get the hardened policy. +if systemctl is-active --quiet sshd.service 2>/dev/null; then + if ! as_root systemctl reload sshd.service; then + echo "The hardening config is installed and valid, but sshd did not reload; it takes effect when sshd next restarts." >&2 + exit 0 + fi +fi diff --git a/test/shell.d/sshd-hardening-migration-test.sh b/test/shell.d/sshd-hardening-migration-test.sh new file mode 100755 index 00000000..d48a374a --- /dev/null +++ b/test/shell.d/sshd-hardening-migration-test.sh @@ -0,0 +1,176 @@ +#!/bin/bash + +set -euo pipefail + +source "$(dirname "$0")/base-test.sh" + +test_dir=$(mktemp -d) +trap 'rm -rf "$test_dir"' EXIT + +migration="$ROOT/migrations/1788124236.sh" +stub_bin="$test_dir/bin" +mkdir -p "$stub_bin" + +cat >"$stub_bin/systemctl" <<'STUB' +#!/bin/bash +printf 'systemctl %s\n' "$*" >>"${CALL_LOG:?}" +case "$1 $2" in +"is-enabled --quiet") [[ ${SSHD_ENABLED:-0} == 1 ]] ;; +"is-active --quiet") [[ ${SSHD_ACTIVE:-0} == 1 ]] ;; +"reload sshd.service") [[ ${SSHD_RELOAD_VALID:-1} == 1 ]] ;; +*) exit 2 ;; +esac +STUB + +cat >"$stub_bin/sshd" <<'STUB' +#!/bin/bash +printf 'sshd %s\n' "$*" >>"${CALL_LOG:?}" +case $1 in +-t) [[ ${SSHD_SYNTAX_VALID:-1} == 1 ]] ;; +-T) + printf 'PasswordAuthentication %s\n' "${SSHD_PASSWORD_AUTH:-no}" + printf 'KbdInteractiveAuthentication %s\n' "${SSHD_KBD_AUTH:-no}" + ;; +*) exit 2 ;; +esac +STUB + +cat >"$stub_bin/sudo" <<'STUB' +#!/bin/bash +printf 'sudo %s\n' "$*" >>"${CALL_LOG:?}" +if [[ ${SUDO_ALLOWED:-1} != 1 ]]; then + exit 1 +fi +exec "$@" +STUB + +chmod +x "$stub_bin"/* + +ssh-keygen -q -t ed25519 -N "" -f "$test_dir/key" +public_key=$(<"$test_dir/key.pub") + +run_migration() { + local scenario=$1 + local home="$test_dir/$scenario/home" + local root="$test_dir/$scenario/root" + local config="$root/etc/ssh/sshd_config.d/10-omarchy-hardening.conf" + + mkdir -p "$home/.ssh" "${config%/*}" + chmod "${HOME_MODE:-755}" "$home" + : >"$test_dir/$scenario.calls" + case "${AUTHORIZED_KEY_STATE:-valid}" in + valid) printf '%s\n' "$public_key" >"$home/.ssh/authorized_keys" ;; + invalid) printf 'not a public key\n' >"$home/.ssh/authorized_keys" ;; + private) cat "$test_dir/key" >"$home/.ssh/authorized_keys" ;; + esac + if [[ ${LOOSE_SSH_PERMS:-0} == 1 ]]; then + chmod 755 "$home/.ssh" + chmod 644 "$home/.ssh/authorized_keys" + fi + if [[ ${ALREADY_HARDENED:-0} == 1 ]]; then + printf 'PasswordAuthentication no\n' >"$config" + fi + + # Keep the privileged production destination fixed in the shipped migration. + # For this isolated test only, rewrite that one assignment in the input fed to + # bash so no scenario can touch the host's /etc. + sed "s|^config=/etc/ssh/sshd_config.d/10-omarchy-hardening.conf$|config=$config|" "$migration" | + HOME="$home" CALL_LOG="$test_dir/$scenario.calls" PATH="$stub_bin:$PATH" \ + SSHD_ENABLED="${SSHD_ENABLED:-0}" SSHD_ACTIVE="${SSHD_ACTIVE:-0}" \ + SSHD_SYNTAX_VALID="${SSHD_SYNTAX_VALID:-1}" \ + SSHD_PASSWORD_AUTH="${SSHD_PASSWORD_AUTH:-no}" \ + SSHD_KBD_AUTH="${SSHD_KBD_AUTH:-no}" \ + SSHD_RELOAD_VALID="${SSHD_RELOAD_VALID:-1}" \ + SUDO_ALLOWED="${SUDO_ALLOWED:-1}" \ + bash -euo pipefail +} + +SSHD_ENABLED=0 SSHD_ACTIVE=0 run_migration disabled +[[ ! -e $test_dir/disabled/root/etc/ssh/sshd_config.d/10-omarchy-hardening.conf ]] || + fail "SSH migration leaves a disabled daemon alone" +! grep -q '^sudo ' "$test_dir/disabled.calls" || fail "disabled SSH does not prompt for privileges" +pass "SSH migration no-ops when sshd is not enabled or active" + +ALREADY_HARDENED=1 SSHD_ENABLED=1 SSHD_ACTIVE=1 run_migration hardened >/dev/null +[[ ! -s $test_dir/hardened.calls ]] || fail "an already-hardened machine must not touch sshd or prompt" +grep -qxF "PasswordAuthentication no" "$test_dir/hardened/root/etc/ssh/sshd_config.d/10-omarchy-hardening.conf" || + fail "the existing hardening config is left alone" +pass "SSH migration no-ops when the hardening config already exists" + +AUTHORIZED_KEY_STATE=missing SSHD_ENABLED=1 run_migration no-key >/dev/null +[[ ! -e $test_dir/no-key/root/etc/ssh/sshd_config.d/10-omarchy-hardening.conf ]] || + fail "SSH migration must not disable passwords without an authorized key" +! grep -q '^sudo ' "$test_dir/no-key.calls" || fail "missing SSH key does not prompt for privileges" + +AUTHORIZED_KEY_STATE=invalid SSHD_ENABLED=1 run_migration invalid-key >/dev/null +[[ ! -e $test_dir/invalid-key/root/etc/ssh/sshd_config.d/10-omarchy-hardening.conf ]] || + fail "SSH migration must not trust a malformed authorized_keys file" +pass "SSH migration requires a usable authorized key before disabling passwords" + +# ssh-keygen -lf accepts a whole private-key file, so only a per-line check +# catches the classic `cp id_ed25519 authorized_keys` slip that sshd cannot use. +AUTHORIZED_KEY_STATE=private SSHD_ENABLED=1 run_migration private-key >/dev/null +[[ ! -e $test_dir/private-key/root/etc/ssh/sshd_config.d/10-omarchy-hardening.conf ]] || + fail "SSH migration must not treat a private key as an authorized key" +! grep -q '^sudo ' "$test_dir/private-key.calls" || fail "a private-key authorized_keys does not prompt for privileges" +pass "SSH migration rejects an authorized_keys holding a private key" + +# StrictModes makes sshd ignore authorized_keys under a group-writable home, +# so the key that validated would be unusable and passwords the only way in. +HOME_MODE=775 SSHD_ENABLED=1 run_migration loose-home >/dev/null +[[ ! -e $test_dir/loose-home/root/etc/ssh/sshd_config.d/10-omarchy-hardening.conf ]] || + fail "SSH migration must not disable passwords when sshd would ignore the key" +! grep -q '^sudo ' "$test_dir/loose-home.calls" || fail "a group-writable home does not prompt for privileges" +pass "SSH migration leaves a group-writable home directory alone" + +LOOSE_SSH_PERMS=1 SSHD_ENABLED=1 SSHD_ACTIVE=1 run_migration active >/dev/null +config="$test_dir/active/root/etc/ssh/sshd_config.d/10-omarchy-hardening.conf" +grep -qxF "PasswordAuthentication no" "$config" || fail "SSH migration disables password authentication" +grep -qxF "KbdInteractiveAuthentication no" "$config" || fail "SSH migration disables keyboard-interactive authentication" +[[ $(stat -c '%a' "$test_dir/active/home/.ssh") == "700" ]] || + fail "SSH migration tightens ~/.ssh so StrictModes accepts the key" +[[ $(stat -c '%a' "$test_dir/active/home/.ssh/authorized_keys") == "600" ]] || + fail "SSH migration tightens authorized_keys so StrictModes accepts the key" +grep -qxF "sudo sshd -t" "$test_dir/active.calls" || fail "SSH migration validates sshd syntax" +grep -qxF "sudo sshd -T" "$test_dir/active.calls" || fail "SSH migration validates effective sshd settings" +grep -qxF "sudo systemctl reload sshd.service" "$test_dir/active.calls" || fail "SSH migration reloads an active daemon" +pass "SSH migration hardens and reloads an existing key-based SSH setup" + +SSHD_ENABLED=1 SSHD_ACTIVE=0 run_migration stopped >/dev/null +[[ -e $test_dir/stopped/root/etc/ssh/sshd_config.d/10-omarchy-hardening.conf ]] || + fail "SSH migration hardens an enabled but stopped daemon" +! grep -qF 'reload sshd.service' "$test_dir/stopped.calls" || fail "SSH migration must not start or reload a stopped daemon" +pass "SSH migration hardens an enabled daemon without starting it" + +# Conditions the migration cannot repair complete with a notice — leaving the +# machine as it was — so they never block the migrations queued behind this one. +SSHD_ENABLED=1 SSHD_ACTIVE=1 SSHD_PASSWORD_AUTH=yes run_migration ineffective >"$test_dir/ineffective.output" 2>&1 || + fail "an ineffective drop-in must complete without blocking later migrations" +[[ ! -e $test_dir/ineffective/root/etc/ssh/sshd_config.d/10-omarchy-hardening.conf ]] || + fail "SSH migration removes an ineffective config" +! grep -qF 'reload sshd.service' "$test_dir/ineffective.calls" || fail "SSH migration must not reload ineffective hardening" +pass "SSH migration backs off when another rule keeps password authentication enabled" + +SSHD_ENABLED=1 SSHD_ACTIVE=1 SSHD_SYNTAX_VALID=0 run_migration invalid-config >"$test_dir/invalid-config.output" 2>&1 || + fail "a rejected config must complete without blocking later migrations" +[[ ! -e $test_dir/invalid-config/root/etc/ssh/sshd_config.d/10-omarchy-hardening.conf ]] || + fail "SSH migration removes a rejected config" +! grep -qF 'reload sshd.service' "$test_dir/invalid-config.calls" || fail "SSH migration must not reload rejected hardening" +pass "SSH migration backs off when sshd rejects the config" + +# The installed config is valid, so a failed reload only delays it until the +# next sshd restart; keep it staged rather than failing or removing it. +SSHD_ENABLED=1 SSHD_ACTIVE=1 SSHD_RELOAD_VALID=0 run_migration reload-fail >"$test_dir/reload-fail.output" 2>&1 || + fail "a failed reload must complete without blocking later migrations" +[[ -e $test_dir/reload-fail/root/etc/ssh/sshd_config.d/10-omarchy-hardening.conf ]] || + fail "a failed reload keeps the valid hardening config staged" +pass "SSH migration keeps the hardening staged when sshd cannot reload" + +# Privileges are the one genuinely retryable failure: stay pending so the +# login notifier prompts for a terminal run. +if SUDO_ALLOWED=0 SSHD_ENABLED=1 SSHD_ACTIVE=1 run_migration no-sudo >"$test_dir/no-sudo.output" 2>&1; then + fail "SSH migration must stay pending when privileges are unavailable" +fi +[[ ! -e $test_dir/no-sudo/root/etc/ssh/sshd_config.d/10-omarchy-hardening.conf ]] || + fail "no hardening config is left behind without privileges" +pass "SSH migration stays pending until privileges are granted"