references/sshd-config.md
# OpenSSH policy and activation
## Effective values and conditional rules
OpenSSH generally uses the first obtained value for each option, with documented exceptions for accumulating lists. Includes are processed where they occur. An early managed filename helps only when the main file actually includes it before competing settings. Existing `AllowUsers` and `AllowGroups` can accumulate, so adding a new list is not necessarily a replacement restriction.
```bash
sudo sshd -t
sudo sshd -T -C user=administrator,host=client.example.com,addr=203.0.113.10
sudo ss -ltnp
sudo systemctl status ssh.service sshd.service ssh.socket --no-pager
```
Inspect every relevant user/address/hostname combination affected by `Match`. Test root and required automation accounts as well. `sshd -T` describes the file configuration, not whether the running daemon has loaded it. Socket units or a custom daemon command line may own the actual listener; preserve their configured ports.
## Key-only policy and MFA
The conventional helper policy is:
```text
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
AuthenticationMethods publickey
PermitEmptyPasswords no
```
Do not apply it over intentional MFA, certificates/SSO or special account requirements without reviewing their authentication flow. `PasswordAuthentication no` alone does not disable a PAM-backed keyboard-interactive password path. Use `KbdInteractiveAuthentication` for current OpenSSH; `ChallengeResponseAuthentication` is an older alias.
Remove obsolete `Protocol 2` settings. Prefer the supported distribution's maintained crypto defaults over copied fixed cipher/KEX lists; make policy-specific overrides only after checking clients, FIPS requirements and the installed OpenSSH version.
Keep existing ports and access/forwarding rules unless the requested scope changes them. `ClientAliveInterval` checks responsiveness; it is not a user-idle timeout. Restrict forwarding only when compatible with required tunnels/deployment workflows.
## Local keys and explicit login tests
Generate personal keys on the local client, not the server:
```bash
ssh-keygen -t ed25519 -f ~/.ssh/server_admin -C server-administration
ssh-copy-id -i ~/.ssh/server_admin.pub administrator@server.example.com
ssh -o IdentitiesOnly=yes -o PreferredAuthentications=publickey -i ~/.ssh/server_admin administrator@server.example.com
```
Use a passphrase or hardware-backed key where supported. Never overwrite an existing key to satisfy a setup recipe. The helper requires an explicit destination and protects both private/public paths; unattended no-passphrase generation is only an explicit service-key choice. Secure the private key and provision only the public key on the server.
Verify `sudo` from that fresh key session before disabling fallback access. Keep a provider console and the old session during the change.
## Validate, activate, reconnect
Validate syntax and effective policy after staging. Find the actual active unit; reload that unit after authorization rather than assuming every distribution uses `sshd.service`. The helper supports active `ssh.service` or `sshd.service`; custom service/socket arrangements require manual inspection.
After reload, open a new connection using the selected key, verify sudo and required automation workflows, and test that disabled authentication methods/root access are rejected. Keep the existing connection until these tests pass. A connection before reload tests the old policy only.
## Exact rollback
The helper saves the precise managed drop-in it overwrites, not just the main `sshd_config`. On validation or activation failure it restores that file or removes the newly created file when none existed, then attempts a recovery reload if activation was attempted. Its printed rollback directory is private.
For a later rollback, restore the saved managed file, or remove only the helper's new managed file, validate syntax and effective policy, then reload the actual unit using the console/preserved session. Do not delete unrelated drop-ins or replace the whole SSH configuration.
## Sources
- [OpenSSH sshd_config manual](https://man.openbsd.org/sshd_config)
- [OpenSSH sshd test modes](https://man.openbsd.org/sshd)
- [Ubuntu OpenSSH server](https://ubuntu.com/server/docs/how-to/security/openssh-server/)
scripts/generate-ssh-keys.sh
#!/usr/bin/env bash
# Run on the local client. Personal keys should have a passphrase.
set -Eeuo pipefail
umask 077
apply=false
explicit_dry_run=false
interactive=false
no_passphrase=false
key_file=
comment=
usage() {
cat <<'HELP'
Usage: generate-ssh-keys.sh --key-file ABSOLUTE_PATH --comment LABEL [OPTIONS]
Preview by default. Never overwrite an existing private or public key.
--apply Generate on this local client
--interactive Human terminal only; ssh-keygen asks for a passphrase
--no-passphrase Explicit unattended service-key choice; protect access carefully
--dry-run Preview only (default)
--help Show usage
Choose --interactive for personal keys. Agents cannot supply a passphrase via
this helper; ask the user to generate it locally or use a hardware-backed key.
Exit: 0 success/preview, 1 generation failure, 2 invalid input/key already exists.
HELP
}
die() { printf '%s\n' "$*" >&2; exit 2; }
while (($#)); do
case $1 in
--key-file) (($# >= 2)) || die '--key-file needs a value'; key_file=$2; shift 2 ;;
--comment) (($# >= 2)) || die '--comment needs a value'; comment=$2; shift 2 ;;
--apply) apply=true; shift ;;
--interactive) interactive=true; shift ;;
--no-passphrase) no_passphrase=true; shift ;;
--dry-run) explicit_dry_run=true; shift ;;
--help|-h) usage; exit 0 ;;
*) die "Unknown option: $1" ;;
esac
done
if $apply && $explicit_dry_run; then die 'Choose --apply or --dry-run, not both.'; fi
[[ $key_file == /* && -n $comment ]] || die 'Supply an absolute --key-file and a nonempty --comment.'
[[ ! -e $key_file && ! -L $key_file && ! -e $key_file.pub && ! -L $key_file.pub ]] || die 'An existing key or public-key path must not be replaced.'
if $interactive && $no_passphrase; then die 'Choose one passphrase mode.'; fi
printf 'New private key: %s\nNew public key: %s.pub\n' "$key_file" "$key_file"
if ! $apply; then printf 'Preview only; run this helper on the LOCAL client.\n'; exit 0; fi
if $interactive; then [[ -t 0 ]] || die '--interactive requires a human terminal.'
elif ! $no_passphrase; then die 'Choose --interactive or explicitly --no-passphrase.'; fi
parent=$(dirname -- "$key_file")
mkdir -p -- "$parent"
[[ -O $parent && ! -L $parent ]] || die 'The key directory must be owned by the current user and not a symlink.'
# Reject a directory another user can write; do not alter existing directory modes.
if [[ $(find "$parent" -prune \( -perm -020 -o -perm -002 \) -print) ]]; then die 'The key directory must not be writable by group/others.'; fi
work=$(mktemp -d "$parent/.key-generation.XXXXXXXX")
trap 'rm -rf -- "$work"' EXIT
if $interactive; then ssh-keygen -t ed25519 -C "$comment" -f "$work/key"
else ssh-keygen -q -t ed25519 -N '' -C "$comment" -f "$work/key"; fi
# Hard links fail on an existing destination instead of overwriting it.
ln -- "$work/key" "$key_file"
if ! ln -- "$work/key.pub" "$key_file.pub"; then
if [[ $key_file -ef $work/key ]]; then rm -f -- "$key_file"; fi
exit 1
fi
printf 'Key pair created. Test with: ssh -o IdentitiesOnly=yes -i "%s" user@host\n' "$key_file"
scripts/setup-ssh-hardening.sh
#!/usr/bin/env bash
# Key-only policy for a conventional Ubuntu/Debian OpenSSH service.
set -Eeuo pipefail
umask 077
apply=false
explicit_dry_run=false
activate=false
confirmed=false
users=()
connection=${SSH_CONNECTION:-}
client_address=${connection%% *}
client_host=
usage() {
cat <<'HELP'
Usage: setup-ssh-hardening.sh --user USER [--user USER] --client-address IP [OPTIONS]
Preview by default. Requires an existing non-root administrator with working keys.
--client-host HOST Connection hostname for Match rules (default: IP)
--key-access-confirmed Attest fresh key login and sudo access for every user
--apply Write and validate a managed drop-in; root required
--activate Reload the active SSH service after validation
--dry-run Preview only (default)
--help Show usage
Keep the current connection and provider console available. After activation,
verify a fresh key login and sudo access. Do not use this helper to replace MFA.
Exit: 0 success/preview, 1 configuration/activation failure, 2 invalid input.
HELP
}
die() { printf '%s\n' "$*" >&2; exit 2; }
while (($#)); do
case $1 in
--user) (($# >= 2)) || die '--user needs a value'; users+=("$2"); shift 2 ;;
--client-address) (($# >= 2)) || die '--client-address needs a value'; client_address=$2; shift 2 ;;
--client-host) (($# >= 2)) || die '--client-host needs a value'; client_host=$2; shift 2 ;;
--key-access-confirmed) confirmed=true; shift ;;
--apply) apply=true; shift ;;
--activate) activate=true; shift ;;
--dry-run) explicit_dry_run=true; shift ;;
--help|-h) usage; exit 0 ;;
*) die "Unknown option: $1" ;;
esac
done
if $apply && $explicit_dry_run; then die 'Choose --apply or --dry-run, not both.'; fi
((${#users[@]})) || die 'Supply at least one existing --user.'
python3 -c 'import ipaddress,sys; ipaddress.ip_address(sys.argv[1])' "$client_address" >/dev/null 2>&1 || die 'Supply a valid actual client IP with --client-address (Python 3 required).'
client_host=${client_host:-$client_address}
[[ $client_host =~ ^[a-zA-Z0-9_.:-]+$ ]] || die 'Invalid --client-host.'
for user in "${users[@]}"; do
[[ $user =~ ^[a-z_][a-z0-9_-]*[$]?$ ]] || die "Invalid username: $user"
uid=$(id -u "$user") || die "User does not exist: $user"
[[ $uid != 0 ]] || die 'A root account cannot be the administrator for this policy.'
done
sshd_bin=$(command -v sshd || true)
sshd_bin=${sshd_bin:-/usr/sbin/sshd}
[[ -x $sshd_bin ]] || die 'Install OpenSSH server before using this helper.'
"$sshd_bin" -t
# Do not silently remove intentional multiple-factor authentication.
for user in "${users[@]}"; do
effective=$("$sshd_bin" -T -C "user=$user,host=$client_host,addr=$client_address")
methods=$(awk '$1=="authenticationmethods" {print $2}' <<< "$effective")
[[ $methods == any || $methods == publickey ]] || die 'Existing MFA/custom authentication requires a reviewed manual policy.'
done
managed=/etc/ssh/sshd_config.d/00-secure-server-hardening.conf
[[ -d /etc/ssh/sshd_config.d && ! -L $managed ]] || die 'Inspect the SSH include directory and remove ambiguous symlink targets first.'
service=
if $activate; then
$apply || die '--activate requires --apply.'
for unit in ssh.service sshd.service; do
if systemctl is-active --quiet "$unit"; then service=$unit; break; fi
done
[[ -n $service ]] || die 'No active conventional SSH service found; inspect service/socket activation manually.'
fi
printf 'Managed file: %s\nUsers to verify: %s\n' "$managed" "${users[*]}"
cat <<'POLICY'
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
AuthenticationMethods publickey
PermitEmptyPasswords no
POLICY
if ! $apply; then
printf 'Preview only. Existing AllowUsers/AllowGroups, ports and forwarding settings are preserved.\n'
exit 0
fi
[[ $EUID == 0 ]] || die '--apply requires root.'
$confirmed || die 'Verify fresh key login and sudo first; supply --key-access-confirmed.'
backup_root=/var/backups/secure-server-setup/ssh
mkdir -p "$backup_root"
chmod 700 "$backup_root"
backup=$(mktemp -d "$backup_root/rollback.XXXXXXXX")
existed=false
touched=false
activation_attempted=false
if [[ -f $managed ]]; then cp -p -- "$managed" "$backup/previous.conf"; existed=true; fi
rollback() {
rc=$?
trap - EXIT
if ((rc != 0)) && $touched; then
if $existed; then cp -p -- "$backup/previous.conf" "$managed"; else rm -f -- "$managed"; fi
if $activation_attempted; then
systemctl reload "$service" || printf 'Recovery reload failed; use the console and backup %s\n' "$backup" >&2
fi
printf 'Restored the previous managed drop-in. Backup: %s\n' "$backup" >&2
fi
exit "$rc"
}
trap rollback EXIT
cat > "$backup/candidate.conf" <<'POLICY'
# Managed by secure-server-setup-skills. Other access rules remain unchanged.
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
AuthenticationMethods publickey
PermitEmptyPasswords no
POLICY
touched=true
install -m 600 -- "$backup/candidate.conf" "$managed"
"$sshd_bin" -t
for user in "${users[@]}" root; do
effective=$("$sshd_bin" -T -C "user=$user,host=$client_host,addr=$client_address")
for setting in 'permitrootlogin no' 'passwordauthentication no' 'kbdinteractiveauthentication no' 'pubkeyauthentication yes' 'authenticationmethods publickey'; do
if ! grep -qxF "$setting" <<< "$effective"; then
printf 'Effective policy mismatch for %s: %s. Inspect Include order and Match rules.\n' "$user" "$setting" >&2
exit 1
fi
done
done
if $activate; then
activation_attempted=true
systemctl reload "$service"
systemctl is-active --quiet "$service"
printf 'SSH reload succeeded. Verify a fresh key login and sudo; keep your original session open.\n'
else
printf 'Policy staged and effective file settings validated; the running daemon has not been reloaded.\n'
fi
printf 'Rollback directory: %s\n' "$backup"
SKILL.md
---
name: ssh-hardening
description: Audit or harden OpenSSH on Ubuntu/Debian with verified key access. Use when changing authentication or administrative SSH access.
license: MIT
compatibility: Ubuntu/Debian Linux with OpenSSH, Python 3, Bash, coreutils, and systemd for activation; local SSH client for access tests.
metadata:
author: secure-server-skill
version: "2.0.0"
category: security
---
# SSH hardening
Establish working administrative access before restricting authentication. Keep the current session and provider console available until a fresh post-change login succeeds.
## Inspect and establish access
1. Identify the active daemon/service/socket, actual listener ports, administrative and automation identities, `Include` order, and relevant `Match` rules. Record `sshd -T -C` results for the actual users, client addresses, and hostnames.
2. Use an existing non-root administrator. If account creation is needed, create it separately with the distribution's user/group conventions, install its public key, then prove key login and sudo in a separate session before any lockdown.
3. Generate personal keys on the local client with a passphrase or hardware-backed key. [scripts/generate-ssh-keys.sh](scripts/generate-ssh-keys.sh) provides preview/no-overwrite behavior; its `--interactive` mode is for a human terminal and `--no-passphrase` is an explicit service-key choice.
4. Test the specific key without password fallback:
```bash
ssh -o IdentitiesOnly=yes -o PreferredAuthentications=publickey -i ~/.ssh/server_admin administrator@server.example.com
```
In that new session, verify the required sudo operation. Password fallback or an already-open session is not proof of usable key access.
## Configure the authorized policy
Read [references/sshd-config.md](references/sshd-config.md) before editing includes, conditional access or activation.
The [setup helper](scripts/setup-ssh-hardening.sh) supports a conventional key-only policy. It requires an existing non-root administrator; it does not create accounts, replace `AllowUsers`/`AllowGroups`, change ports, or remove forwarding functionality. Existing multiple-factor authentication requires a reviewed manual policy instead of this helper.
```bash
sudo bash scripts/setup-ssh-hardening.sh --user administrator --client-address 203.0.113.10 --dry-run
```
After proven key/sudo access and authorization to configure/activate:
```bash
sudo bash scripts/setup-ssh-hardening.sh --user administrator --client-address 203.0.113.10 --key-access-confirmed --apply --activate
```
Repeat `--user` for administrators that must be verified. Set `--client-host` when hostname-based `Match` rules apply. The helper validates the supplied client context; inspect other relevant networks/hostnames as well. It writes a managed early drop-in and refuses success if earlier values or conditional rules override the requested policy. An early filename alone is not proof of precedence.
## Verify and recover
- Syntax: `sudo sshd -t`. Effective file policy: `sudo sshd -T -C user=administrator,host=client.example.com,addr=203.0.113.10`.
- Verify root, password and keyboard-interactive restrictions for all relevant contexts; ensure required key login and any intended MFA remain possible.
- After activation, establish a fresh login with the specific key and check sudo. Test denied authentication methods from a controlled client. Preserve the original session until these pass.
- If the script fails during validation or activation, it restores the exact previous managed drop-in and attempts to reload the restored policy when needed. For later rollback, use the printed protected backup directory; restore its saved file or remove the new managed file if none existed. Then validate and reload the actual service from the preserved session/console.
Report file validation separately from activation and live access tests. Do not describe `ClientAliveInterval` as a user-idle timeout or hard-code obsolete protocol/crypto settings.