[$ xmrhost] _

$ xmrhost-cli docs show --topic=harden-sshd

[$ ] doc: harden-sshd

// Harden sshd on a fresh Debian 12 VPS — sshd_config, fail2ban, auditd, host-key rotation

// published=2026-04-29 · updated=2026-04-29 · diff=advanced · read=24min · tags=[ssh, sshd, ed25519, hardening, fail2ban]


// ABSTRACT

abstract

The full sshd lockdown procedure for an offshore XMRHost VPS. Pinned KEX/cipher/MAC suites, Ed25519-only keys, fail2ban with a recidive jail, auditd rules for sshd and sudo, host-key rotation that survives an in-flight session, and the four post-reload checks (port still 22, no password auth, key fingerprint matches, fail2ban active). Replaces the bare-floor sshd block in `provision-tor-hidden-service` step 6.

Scope and assumptions

This is the long-form sshd hardening procedure that the rest of the manual references with [harden-sshd]. Baseline: Debian 12 (bookworm) on a fresh XMRHost VPS, you have console access (the VPS provider’s serial / VNC console — the procedure here will NOT lock you out, but you should always have an out-of-band path before touching /etc/ssh/sshd_config), and you are signing in over SSH from a workstation that already runs an SSH agent with at least one Ed25519 keypair.

This doc is opinionated: it says no to RSA-only deployments, no to password auth (even for “the bootstrap session”), no to PAM-only-with-2FA-on-the-side, and no to Match rules for “trusted” subnets. The XMRHost threat model assumes the upstream network is hostile, the box is a target, and the cost of a compromise is the entire onion identity (per [provision-tor-hidden-service]) plus everything downstream.

If you are coming here from the bare-floor sshd block in provision-tor-hidden-service step 6, replace that block with the procedure here in full. Do not mix the two.

Step 1 — generate an Ed25519 keypair on the workstation, NOT on the server

Old habit from the RSA era: SSH into a fresh box, run ssh-keygen on the server, copy the key to wherever it needs to go. Stop doing that. The private key is generated on the workstation, never leaves the workstation, and the server only ever sees the public key.

# On your workstation. -t ed25519 picks the algorithm; -a 100 sets KDF rounds
# for the passphrase (resistant to GPU brute force). The comment is purely
# informational — sshd does not parse it.
ssh-keygen -t ed25519 -a 100 -C "operator@$(hostname)-$(date +%Y-%m)" -f ~/.ssh/id_ed25519_xmrhost

The -a 100 parameter sets the bcrypt KDF rounds for the passphrase-derived key encryption. [RFC 8709] defines Ed25519 for SSH; the algorithm itself is fixed-256-bit, so the only knob the user has is the passphrase strength and the -a rounds, both of which protect against an attacker who later steals the on-disk private key file.

Output of the key generation:

ls -la ~/.ssh/id_ed25519_xmrhost*
-rw-------  1 you you  444 May 06 22:14 /home/you/.ssh/id_ed25519_xmrhost
-rw-r--r--  1 you you  104 May 06 22:14 /home/you/.ssh/id_ed25519_xmrhost.pub

The private file is 0600. If umask was loose at generation time and yours came out 0644, fix it before continuing — the OpenSSH client refuses keys with permissive permissions, and a 0644 private key sitting in your home dir is a footgun that fires on the next time someone else has shell access.

Push the public key only to the server’s ~/.ssh/authorized_keys. If you already have a working session, append it; if not, the VPS provider’s first-boot console will let you paste it.

# From your workstation, append to root's authorized_keys on the box.
ssh-copy-id -i ~/.ssh/id_ed25519_xmrhost.pub root@<vps-ip>

Verify on the server side:

# On the server.
cat ~/.ssh/authorized_keys
chmod 0700 /root/.ssh
chmod 0600 /root/.ssh/authorized_keys

Confirm the new key works in a SECOND terminal before rotating to the hardened config — the existing session is your safety net if the new config breaks something.

Step 2 — the canonical sshd_config

Drop the following into /etc/ssh/sshd_config.d/00-xmrhost.conf rather than editing /etc/ssh/sshd_config directly. Debian 12 ships with Include /etc/ssh/sshd_config.d/*.conf at the top of the main config; per-vendor drop-in files are easier to diff against the package default and survive a dpkg --purge --reinstall cleanly.

# /etc/ssh/sshd_config.d/00-xmrhost.conf
# XMRHost hardened sshd. Drop into /etc/ssh/sshd_config.d/, run `sshd -t`,
# then `systemctl reload sshd`. Validated against OpenSSH_9.2p1 on Debian 12.

# ---------- network ---------------------------------------------------------
# IPv4 only by default. If the VPS has working IPv6 and you want it, set
# AddressFamily any and bind to both ::: and 0.0.0.0:.
Port                              22
AddressFamily                     inet
ListenAddress                     0.0.0.0

# ---------- protocol --------------------------------------------------------
# Modern KEX / Cipher / MAC sets. Pin them so an upstream default change
# doesn't move the policy under us. The order matters — left-to-right is
# preference order.
KexAlgorithms                     curve25519-sha256,curve25519-sha256@libssh.org
HostKeyAlgorithms                 ssh-ed25519,rsa-sha2-512
PubkeyAcceptedAlgorithms          ssh-ed25519,rsa-sha2-512
Ciphers                           chacha20-poly1305@openssh.com,aes256-gcm@openssh.com
MACs                              hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com

# ---------- host keys -------------------------------------------------------
# RSA keys are kept ONLY because some legacy clients still default to them;
# Ed25519 is preferred. The DSA and ECDSA keys are not loaded — they default
# to commented-out in modern Debian, but be explicit.
HostKey                           /etc/ssh/ssh_host_ed25519_key
HostKey                           /etc/ssh/ssh_host_rsa_key

# ---------- authentication --------------------------------------------------
PermitRootLogin                   prohibit-password
PasswordAuthentication            no
PermitEmptyPasswords              no
ChallengeResponseAuthentication   no
KbdInteractiveAuthentication      no
HostbasedAuthentication           no
PubkeyAuthentication              yes
UsePAM                            yes
AuthenticationMethods             publickey

# ---------- account / session shaping --------------------------------------
LoginGraceTime                    20
MaxAuthTries                      3
MaxSessions                       3
MaxStartups                       3:30:10
ClientAliveInterval               300
ClientAliveCountMax               2
PermitTunnel                      no
AllowTcpForwarding                yes
GatewayPorts                      no
X11Forwarding                     no
AllowAgentForwarding              no
PrintMotd                         no

# ---------- logging ---------------------------------------------------------
LogLevel                          VERBOSE
SyslogFacility                    AUTH

# ---------- legal banner ----------------------------------------------------
# A pre-auth banner. Some operators consider this performative; some
# jurisdictions require it for unauthorised-access prosecutions to stick.
# Default to having one.
Banner                            /etc/ssh/banner.txt

AuthenticationMethods publickey is the explicit public-key-only directive that backstops PasswordAuthentication no — even if a future drop-in or Match rule re-enables passwords, this line forces every connection through publickey first. [OpenSSH 8.0+ release notes — AuthenticationMethods semantics]

The LogLevel VERBOSE toggle is the difference between “fail2ban can match” and “fail2ban silently misses every brute-force attempt” — VERBOSE is the level at which sshd logs every key-attempt fingerprint, which is also what fail2ban’s default sshd-aggressive filter parses against.

Write the banner:

cat > /etc/ssh/banner.txt <<'EOF'
This system is operated by the customer of XMRHost. Authorised access only.
All connection attempts are logged. Disconnect now if you are not authorised.
EOF

Test the config and reload:

sshd -t  # validate without restarting
systemctl reload sshd

If sshd -t errors, the reload is a no-op and the existing daemon keeps running on the previous config. Do NOT fall through to systemctl restart sshd until sshd -t passes — restart will pick up the broken config and existing sessions are immune but new ones lock out.

Step 3 — verify with a second session

Open a SECOND terminal and reconnect with the new keypair. Do NOT close the existing session until the second session is confirmed working.

ssh -v -i ~/.ssh/id_ed25519_xmrhost root@<vps-ip>
OpenSSH_9.2p1, OpenSSL 3.0.11 19 Sep 2023
debug1: Reading configuration data /home/you/.ssh/config
debug1: Connecting to <vps-ip> port 22.
debug1: Connection established.
debug1: identity file /home/you/.ssh/id_ed25519_xmrhost type 3
debug1: kex: algorithm: curve25519-sha256
debug1: kex: host key algorithm: ssh-ed25519
debug1: cipher: chacha20-poly1305@openssh.com MAC: <implicit>
debug1: Authentications that can continue: publickey
debug1: Offering public key: ED25519 SHA256:abc... id_ed25519_xmrhost
debug1: Authentication succeeded (publickey).

The four lines worth confirming:

  • kex: algorithm: curve25519-sha256 — KEX pinned correctly
  • kex: host key algorithm: ssh-ed25519 — server presented its Ed25519 host key
  • cipher: chacha20-poly1305@openssh.com — cipher pinned correctly
  • Authentications that can continue: publickey — passwords are NOT in the offered list

If any of those four are wrong, fix the config before closing the first session.

Step 4 — fail2ban with a recidive jail

fail2ban watches /var/log/auth.log (the SyslogFacility AUTH we set above) and adds short-term IP bans via nftables. The default Debian ship comes with the sshd jail enabled but with relatively forgiving thresholds. The brand baseline tightens those AND adds a recidive jail that catches IPs which keep re-offending after the short ban expires.

Install:

apt install -y fail2ban

Drop a brand override into /etc/fail2ban/jail.d/xmrhost.conf:

# /etc/fail2ban/jail.d/xmrhost.conf — XMRHost tighter sshd jail.

[DEFAULT]
# Use nftables for the actual ban (Debian 12 default is iptables-multiport
# which works but interleaves badly with the brand's nft-based egress rules).
banaction        = nftables[type=multiport]
banaction_allports = nftables[type=allports]
# IPs to never ban — the operator's office IP and the VPS provider's
# rescue console IP. Update per-deployment.
ignoreip         = 127.0.0.1/8 ::1

[sshd]
enabled          = true
mode             = aggressive
port             = 22
filter           = sshd[mode=aggressive]
logpath          = %(sshd_log)s
backend          = %(sshd_backend)s
maxretry         = 3
findtime         = 10m
bantime          = 1h

[recidive]
# Catches IPs which trip the sshd jail repeatedly. The bantime escalates to
# a week, the filter watches fail2ban's own log for "Ban" entries.
enabled          = true
logpath          = /var/log/fail2ban.log
maxretry         = 5
findtime         = 1d
bantime          = 1w

Reload fail2ban and confirm the jail is active:

systemctl reload fail2ban
fail2ban-client status sshd
fail2ban-client status recidive
fail2ban-client status sshd
Status for the jail: sshd
|- Filter
|  |- Currently failed: 0
|  |- Total failed:     0
|  `- File list:        /var/log/auth.log
`- Actions
 |- Currently banned: 0
 |- Total banned:     0
 `- Banned IP list:

If Currently banned stays 0 for 24 hours on a public-IP box, the filter is misconfigured — every public-IP VPS sees scan traffic within minutes of boot. The most common cause is LogLevel in sshd_config being below VERBOSE.

Step 5 — auditd rules for sshd, sudo, and key files

The fail2ban layer catches dumb brute force. The auditd layer catches everything else: every successful sshd login, every sudo invocation, every read or write of the host keys, every modification of /etc/ssh/sshd_config.d/. These rules turn the box into a tape recorder for any post-incident forensic pass.

Install:

apt install -y auditd

Drop the brand rule set:

cat > /etc/audit/rules.d/00-xmrhost-sshd.rules <<'EOF'
# XMRHost auditd rules — sshd surface.
# References: ANSSI BP-028 hardening guide; Linux audit framework docs
# (kernel /Documentation/audit/).

# ---- Watches on the sshd config and host keys.
-w /etc/ssh/sshd_config              -p wa -k sshd-config
-w /etc/ssh/sshd_config.d/           -p wa -k sshd-config
-w /etc/ssh/ssh_host_ed25519_key     -p wa -k sshd-hostkeys
-w /etc/ssh/ssh_host_rsa_key         -p wa -k sshd-hostkeys
-w /root/.ssh/authorized_keys        -p wa -k sshd-authkeys
-w /etc/fail2ban/                    -p wa -k fail2ban

# ---- syscall watch on every binary execve (heavy — useful only on
# this small a box; on a busy server, restrict to specific binaries).
-a always,exit -F arch=b64 -S execve -F uid>=1000 -F auid!=4294967295 -k user-exec
-a always,exit -F arch=b64 -S execve -F path=/usr/bin/sudo -k sudo-exec
-a always,exit -F arch=b64 -S execve -F path=/usr/sbin/sshd -k sshd-exec

# ---- Final immutable flag — auditd rules cannot be modified at runtime.
# Reboot required to change the rule set after this lands. Comment it out
# if you're still iterating.
-e 2
EOF

Reload:

augenrules --load
systemctl restart auditd
auditctl -l | head -20

Search the audit log for sshd-config writes the next time you edit it:

ausearch -k sshd-config -ts today | head
time->Wed May  6 22:30:17 2026
type=PATH msg=audit(1715030217.991:142): item=0 name=/etc/ssh/sshd_config.d/00-xmrhost.conf
type=SYSCALL msg=audit(1715030217.991:142): arch=c000003e syscall=257 a2=O_WRONLY|O_CREAT comm="vim" exe="/usr/bin/vim" key="sshd-config"

The -e 2 flag at the end of the rules makes the rule set immutable until reboot. Comment it out while iterating; turn it on once the rule set is stable. The threat model: an attacker who gets root could otherwise simply call auditctl -D and erase the rules silently. [Linux audit framework — auditctl(8) on -e 2]

Step 6 — host-key rotation procedure

The Ed25519 host key on a fresh VPS image is sometimes pre-generated by the upstream provider’s image-build pipeline, which means every customer using that image gets the same host key. [Heninger et al., 'Mining Your Ps and Qs', USENIX Security 2012] documents the historical impact of low-entropy host-key generation; while modern Debian 12 regenerates host keys at first boot via regenerate-ssh-host-keys.service, the brand baseline assumes the upstream provider may have disabled that hook.

Force regeneration on a known-good schedule:

# Stop sshd to make sure no in-flight connection is mid-handshake.
# Existing sessions stay alive — they don't re-authenticate; new connections
# will hit the new host keys.
systemctl stop ssh

# Remove the current host keys.
rm /etc/ssh/ssh_host_*

# Regenerate. -A creates one of each algorithm with default sizes. The
# Ed25519 is fixed-size; the RSA defaults to 3072 in OpenSSH 9.x.
ssh-keygen -A

# Restart sshd.
systemctl start ssh

# Print the new fingerprints — store them OFFLINE for the workstation
# known_hosts pin.
for f in /etc/ssh/ssh_host_*_key.pub; do
  ssh-keygen -lf "$f"
done

Expected output:

ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub
256 SHA256:ZJ8aTpQ9xC3vKbR5wWnL7H6FJ0E2yUaPiTm8sX9oQpY no comment (ED25519)

On the workstation, prune the old fingerprint and re-add:

ssh-keygen -R <vps-ip>
ssh -o StrictHostKeyChecking=accept-new root@<vps-ip>

StrictHostKeyChecking=accept-new accepts unknown keys but refuses CHANGED ones — so this only auto-accepts on the first connection after the rotation, which is what you want.

Step 7 — four post-reload checks

Before closing the second session, work through these four checks. Each one catches a class of misconfiguration that is easy to ship and embarrassing to debug.

Check 1 — port still 22, no extra listener

ss -tlnp | grep -E 'sshd|:22'

Expected: one line for IPv4 sshd on 0.0.0.0:22, optionally one line for IPv6 if you enabled it. Anything else (e.g. sshd listening on 127.0.0.1:222) means a Match rule or a stray Port directive landed somewhere.

Check 2 — password auth genuinely off

sshd -T | grep -E 'passwordauthentication|permitrootlogin|kbdinteractive'
sshd -T | grep passwordauthentication
passwordauthentication no
kbdinteractiveauthentication no

sshd -T dumps the live, post-Match-resolution config — this is the truth, not what’s on disk.

Check 3 — host key fingerprint matches the workstation pin

On the workstation:

ssh-keygen -lf <(ssh-keyscan -t ed25519 <vps-ip> 2>/dev/null)

Compare against your offline-stored fingerprint. If they differ, abort — the box was either reinstalled (which you should have known about) or is being intercepted.

Check 4 — fail2ban, auditd, sshd all running

systemctl is-active sshd fail2ban auditd

Expected: active three times. If any are inactive or failed, check the journal: journalctl -u <service> -n 50 --no-pager.

Closing — what to do next

The sshd surface is now hardened against the brute-force, default-key, and protocol-downgrade attack classes. The next things worth doing, in order of operational benefit:

  1. Read kernel-hardening-checklist and apply the sysctl + AppArmor changes — the brand baseline already covers most of this, but the doc lists the remaining hand-tunes.
  2. Read ssh-key-migration and rotate the operator’s keypair on the same cadence as the host-key rotation (annually, or after any workstation compromise).
  3. If the threat model demands sshd reachable ONLY over an onion service (no clearnet sshd at all), provision the onion-ssh per [provision-tor-hidden-service] step 6’s callout and harden the clearnet sshd to bind 127.0.0.1 only.

Don’t skip the second-session safety net. Every operator who has ever locked themselves out of a box has a story that starts with “I just made one quick edit”.

// FREQUENTLY-ASKED

$ faq -p harden-sshd

Q. Should I disable password authentication entirely?

A. Yes. PasswordAuthentication no, ChallengeResponseAuthentication no, KbdInteractiveAuthentication no. Password auth is the credential-stuffing surface for sshd and is responsible for the bulk of unauthorised-access attempts on a public-IP host. Ed25519 public-key auth is the baseline; OpenSSH 7.0+ supports it everywhere.

Q. Is fail2ban still useful if PasswordAuthentication is off?

A. Yes — fail2ban does more than rate-limit password attempts. The recommended ruleset bans IPs that probe for known credential paths, attempt to guess valid usernames, or spam invalid kex offers. Even a key-only sshd benefits from auto-banning sources of probe traffic so log volume stays readable. fail2ban is cheap; ship it.

Q. What's the right MaxAuthTries?

A. MaxAuthTries 3 is the conservative default. The setting bounds the number of public-key attempts per connection; a misconfigured ssh-agent presenting many keys can trip the limit on a legitimate connection. If you operate a multi-key environment (per-host keys, hardware tokens, etc.) raise to 5; lower has no security benefit.

Q. Should sshd listen on a non-standard port?

A. Optional. Moving sshd to a non-22 port reduces noise volume in the logs from indiscriminate scanners but does not change the security posture against a targeted adversary (every IPv4 host is port-scanned multiple times per day). The honest framing is "port-22-on-22 is a log-volume choice, not a security choice". XMRHost keeps 22 as default and recommends operators leave it there unless they have a specific reason.

// END OF DOC

$ cd /docs # back to the manual