<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"
	xmlns:content="http://purl.org/rss/1.0/modules/content/"
	xmlns:wfw="http://wellformedweb.org/CommentAPI/"
	xmlns:dc="http://purl.org/dc/elements/1.1/"
	xmlns:atom="http://www.w3.org/2005/Atom"
	xmlns:sy="http://purl.org/rss/1.0/modules/syndication/"
	xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
	xmlns:media="http://search.yahoo.com/mrss/" >

<channel>
	<title>381 &#8211; deb.myguard.nl</title>
	<atom:link href="https://deb.myguard.nl/category/381/feed/" rel="self" type="application/rss+xml" />
	<link>https://deb.myguard.nl</link>
	<description>Building packages, building the web</description>
	<lastBuildDate>Mon, 25 May 2026 01:14:15 +0000</lastBuildDate>
	<language>en-US</language>
	<sy:updatePeriod>
	hourly	</sy:updatePeriod>
	<sy:updateFrequency>
	1	</sy:updateFrequency>
	

<image>
	<url>https://deb.myguard.nl/wp-content/uploads/2026/05/deb-myguard-shield-favicon-150x150.png</url>
	<title>381 &#8211; deb.myguard.nl</title>
	<link>https://deb.myguard.nl</link>
	<width>32</width>
	<height>32</height>
</image> 
	<item>
		<title>Hardened OpenSSH 10.3 for Debian and Ubuntu: PQ Crypto, AppArmor, 3 sshd Flavours</title>
		<link>https://deb.myguard.nl/2026/05/hardened-openssh-for-debian-and-ubuntu-pq-crypto-apparmor-three-sshd-flavours/</link>
		
		<dc:creator><![CDATA[Thijs Eilander]]></dc:creator>
		<pubDate>Mon, 25 May 2026 00:41:35 +0000</pubDate>
				<category><![CDATA[381]]></category>
		<category><![CDATA[5234]]></category>
		<category><![CDATA[apparmor]]></category>
		<category><![CDATA[debian]]></category>
		<category><![CDATA[ed25519]]></category>
		<category><![CDATA[fail2ban]]></category>
		<category><![CDATA[hardening]]></category>
		<category><![CDATA[kerberos]]></category>
		<category><![CDATA[openssh]]></category>
		<category><![CDATA[post-quantum]]></category>
		<category><![CDATA[security]]></category>
		<category><![CDATA[ssh]]></category>
		<category><![CDATA[sshd]]></category>
		<category><![CDATA[tls]]></category>
		<category><![CDATA[ubuntu]]></category>
		<guid isPermaLink="false">https://deb.myguard.nl/?p=5883</guid>

					<description><![CDATA[The myguard OpenSSH 10.3 package rebuilds sshd for production servers: post-quantum key exchange, AEAD-only ciphers, an AppArmor profile, a fail2ban jail, monthly moduli regeneration, three switchable sshd flavours (default / gssapi / minimal), and compiler hardening beyond Debian's default. Includes a 2026 SSH key-generation walkthrough and a stack of server-hardening tips.]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">Debian ships an OpenSSH that&#8217;s been packaged for everyone, which is to say: not really for you. The shipped <code>sshd_config</code> is a museum of compromises — X11 forwarding on by default, an algorithm list that still flatters clients from 2014, a single static <code>moduli</code> file that&#8217;s been the same on every Debian box on the planet for years, an AppArmor profile that exists in <code>apparmor-profiles</code> and is never loaded, a systemd unit with no sandboxing whatsoever, and Kerberos compiled in whether you use it or not because someone, somewhere, might be running Active Directory. That&#8217;s a fine posture for a Linux laptop in 2008. It&#8217;s a worse one for a server in 2026.</p>



<p class="wp-block-paragraph">So we rebuilt it. The myguard OpenSSH 10.3 packages for Debian and Ubuntu give you post-quantum hybrid key exchange by default, AEAD-only ciphers, a loaded AppArmor profile, a fail2ban jail that just works, a systemd unit that actually drops capabilities, a monthly moduli refresh timer, compiler flags well past Debian&#8217;s own <code>hardening=+all</code>, and — the bit we&#8217;re most pleased with — three real, apt-installable server packages that let you pick the attack surface you actually need without recompiling anything.</p>



<p class="wp-block-paragraph">What follows is the full tour: what we ship, why every choice was made, the apt commands, a 2026 SSH key recipe (Ed25519, security keys, certificates), and a long tail of operational tips that the Debian default should have absorbed a decade ago.</p>



<h2 class="wp-block-heading" id="why-rebuild">Why rebuild OpenSSH at all?</h2>



<p class="wp-block-paragraph">Let&#8217;s be fair to the Debian maintainers for a sentence: OpenSSH itself, the thing the OpenBSD team writes, is one of the best pieces of security software ever shipped. The problem isn&#8217;t OpenSSH. The problem is what happens to it on the way to your apt mirror.</p>



<p class="wp-block-paragraph">The shipped configuration is built for an imaginary lowest-common-denominator user — a workstation that runs X11, a laptop that might suddenly need to talk to a Solaris box from 2003, an AD-joined desktop that wants Kerberos session tickets. Every default reflects that user. None of them is you.</p>



<p class="wp-block-paragraph">The roll call from a stock <code>apt install openssh-server</code>:</p>



<ul class="wp-block-list">
<li><code>X11Forwarding yes</code> — handy on a desktop, free tunneling for an attacker on a server.</li>
<li><code>KbdInteractiveAuthentication</code> defaults that interact badly with PAM and become a brute-force vector the moment someone re-enables password auth &#8220;just for a minute&#8221;.</li>
<li>Algorithm lists that include <code>ssh-rsa</code>, NIST-P ECDH, <code>diffie-hellman-group14-sha1</code>, and the whole CBC family, because the maintainer can&#8217;t be sure you don&#8217;t have a 2010 router on your network.</li>
<li>One <code>/etc/ssh/moduli</code> file, byte-identical on every install on Earth, never regenerated.</li>
<li>An AppArmor profile sitting unused in another package.</li>
<li>A <code>ssh.service</code> with effectively no <code>ProtectSystem</code>, no <code>RestrictAddressFamilies</code>, no <code>SystemCallFilter</code>, no <code>CapabilityBoundingSet</code> — because someone in #debian-mentors thinks sandboxing is too opinionated.</li>
<li>Kerberos linked into the one <code>sshd</code> binary you get, even though three-quarters of Linux servers will never see a KDC.</li>
</ul>



<p class="wp-block-paragraph">None of this is a bug. It&#8217;s the price of being a general-purpose distribution. The cost is that every serious shop ends up writing the same Ansible role to undo it, every audit firm bills the same hours to re-discover it, and the actual exposed surface of SSH on the open internet stays exactly as wide as it was in 2014. We figured that was enough.</p>



<h2 class="wp-block-heading" id="package-layout">Pick the attack surface you actually need</h2>



<p class="wp-block-paragraph">Debian gives you one <code>openssh-server</code> binary, built with everything: PAM, SELinux, audit, libedit, security keys, wtmpdb, Kerberos. You get all of it whether you wanted it or not, you get <code>libwrap</code> for free even though TCP wrappers haven&#8217;t been a good idea since the Bush administration, and the binary on a hardened bastion in a co-lo is bit-for-bit identical to the one on a developer&#8217;s laptop. We split it into three.</p>



<figure class="wp-block-table"><table><thead><tr><th>Package</th><th>Built with</th><th>Use it when</th></tr></thead><tbody>
<tr><td><strong>openssh-server</strong></td><td>PAM, SELinux, audit, libedit, security keys, wtmpdb. <strong>No Kerberos.</strong></td><td>Every server you have. This is the right default.</td></tr>
<tr><td><strong>openssh-server-gssapi</strong></td><td>Everything <code>openssh-server</code> has, plus Kerberos / GSS-API.</td><td>You&#8217;re actually running Active Directory or an MIT KDC. If you can&#8217;t name your realm without looking it up: not this one.</td></tr>
<tr><td><strong>openssh-server-minimal</strong></td><td>No PAM, no Kerberos, no SELinux, no audit, no libedit, no security keys, no wtmpdb, no xauth.</td><td>Containers, bastion jumphosts, recovery images. <code>authorized_keys</code> is the only auth path. There is no PAM stack to break and no libwrap to deprecate.</td></tr>
</tbody></table></figure>



<p class="wp-block-paragraph">All three install <code>/usr/sbin/sshd</code> and <code>Conflicts:</code> each other, so apt swaps them transactionally. A host that starts as <code>openssh-server</code> and a year later gets folded into AD becomes:</p>



<pre class="wp-block-preformatted"><code>sudo apt install openssh-server-gssapi
</code></pre>



<p class="wp-block-paragraph">and apt removes the old binary, installs the new one, and the unit restarts. No Ansible task, no role variable, no rebuild.</p>



<p class="wp-block-paragraph">The hardening drop-in, the systemd lock-down, the AppArmor profile, the fail2ban jail and the moduli refresh timer all live in a fourth package — <strong>openssh-server-common</strong> — that every flavour <code>Depends:</code> on. Whichever <code>sshd</code> you install, the same hardened defaults apply. You can&#8217;t accidentally have the gssapi flavour without the systemd sandbox; the dependency graph won&#8217;t let you.</p>



<h2 class="wp-block-heading" id="whats-inside">What openssh-server-common ships</h2>



<h3 class="wp-block-heading">1. A hardened <code>sshd_config</code> drop-in that&#8217;s actually removable</h3>



<p class="wp-block-paragraph">We install <code>/etc/ssh/sshd_config.d/00-myguard-hardening.conf</code>. Because OpenSSH applies the <em>first</em> occurrence of any keyword and the shipped <code>sshd_config</code> pulls in <code>sshd_config.d/*.conf</code> before its own body takes effect, this drop-in wins on every conflict. The point isn&#8217;t just to be authoritative — it&#8217;s that any admin who needs to relax one knob writes a <code>99-something.conf</code> next to it. No editing the main file. No conffile-rejected-on-upgrade dialog at four in the morning. The hardening is a file, not a patch.</p>



<p class="wp-block-paragraph">What&#8217;s in it:</p>



<ul class="wp-block-list">
<li><strong>Post-quantum hybrid KEX first:</strong> <code>mlkem768x25519-sha256</code> then <code>sntrup761x25519-sha512</code>, then <code>curve25519-sha256</code>. NIST-P ECDH and SHA-1 DH groups are gone. If &#8220;harvest now, decrypt later&#8221; is a thing, this is the one config line that matters most.</li>
<li><strong>AEAD ciphers only:</strong> <code>chacha20-poly1305</code>, <code>aes256-gcm</code>, <code>aes128-gcm</code>. CBC, 3DES, arcfour and plain CTR are out — they&#8217;ve been bad ideas for various lengths of time between five and twenty years.</li>
<li><strong>ETM MACs only:</strong> <code>hmac-sha2-512-etm</code>, <code>hmac-sha2-256-etm</code>, <code>umac-128-etm</code>. The non-ETM variants leak a length oracle; we don&#8217;t care if your 2013 client doesn&#8217;t like that.</li>
<li><strong>Signatures:</strong> Ed25519, ECDSA P-256/384/521, RSA-SHA2. <code>ssh-rsa</code> (which is RSA with SHA-1) is removed from <code>HostKeyAlgorithms</code>, <code>PubkeyAcceptedAlgorithms</code> <em>and</em> <code>CASignatureAlgorithms</code>. It&#8217;s 2026.</li>
<li><strong>Auth:</strong> password off, root prohibit-password, empty passwords off, keyboard-interactive off, host-based off, rhosts ignored, known-hosts ignored.</li>
<li><strong>Forwarding:</strong> X11 off, TCP off, agent off, tunnel off, user-environment off. If you need any of these on, you&#8217;ll know what you&#8217;re doing and which override to write.</li>
<li><strong>Brute-force economics:</strong> <code>LoginGraceTime 30s</code>, <code>MaxAuthTries 3</code>, <code>MaxStartups 10:30:60</code>, <code>ClientAliveInterval 300</code>, <code>ClientAliveCountMax 2</code>.</li>
<li><strong>PerSourcePenalties</strong> (OpenSSH ≥ 9.8): the killer feature nobody talks about. sshd tarpits abusive sources by /24 (IPv4) or /48 (IPv6) source block, ramping from 5 seconds on an auth failure to a 1-hour cap. fail2ban still helps if you want fancy correlation, but for the simple &#8220;China is scanning port 22 again&#8221; case, you don&#8217;t even need it.</li>
<li><strong>LogLevel VERBOSE</strong> — every successful login emits the key fingerprint that authenticated it. Your SIEM will thank you the first time you have to investigate a key compromise.</li>
</ul>



<h3 class="wp-block-heading">2. A systemd unit that actually sandboxes</h3>



<p class="wp-block-paragraph">The stock <code>ssh.service</code> on Debian is, charitably, conservative. It runs sshd. It restarts on failure. That&#8217;s the security boundary. We drop <code>/usr/lib/systemd/system/ssh.service.d/hardening.conf</code> on top of it, and the systemd merge gives you:</p>



<ul class="wp-block-list">
<li><code>ProtectSystem=strict</code> with <code>ProtectHome=read-only</code> and an explicit, minimal <code>ReadWritePaths=</code>. sshd can write to <code>/var/log</code>, <code>/run/sshd</code>, <code>/var/lib/sss</code> and a few other places that matter. It cannot write anywhere else. If your sshd ever decides to drop a binary in <code>/tmp</code> and run it, that&#8217;s a kernel-enforced &#8220;no&#8221; rather than a hope.</li>
<li><code>CapabilityBoundingSet=</code> whitelisted. sshd ships with a long list of effective capabilities it doesn&#8217;t need; we drop the boundary to the dozen that PAM and the privsep child actually use.</li>
<li><code>MemoryDenyWriteExecute=yes</code>, <code>LockPersonality=yes</code>, <code>RestrictRealtime=yes</code>. The first one is the load-bearing knob in 2026: an exploit that smuggles in shellcode now has to defeat W^X in the address space, not just bypass NX.</li>
<li><code>RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK</code>. No AF_PACKET, no AF_BLUETOOTH, no AF_XDP. sshd is a TCP daemon — it doesn&#8217;t need to learn new tricks.</li>
<li><code>SystemCallFilter=@system-service @resources</code> minus <code>@privileged @debug @mount @reboot @swap @cpu-emulation @obsolete</code>. The kernel rejects most of the syscalls a post-exploitation toolkit reaches for, before sshd&#8217;s process ever sees them.</li>
<li>Plus the alphabet soup: <code>RestrictNamespaces</code>, <code>ProtectKernelTunables</code>, <code>ProtectKernelModules</code>, <code>ProtectKernelLogs</code>, <code>ProtectClock</code>, <code>ProtectHostname</code>, <code>ProtectProc=invisible</code>, <code>ProcSubset=pid</code>.</li>
</ul>



<p class="wp-block-paragraph">One knob we don&#8217;t set: <code>NoNewPrivileges=yes</code>. <code>pam_unix</code> and <code>pam_systemd</code> rely on suid helpers, and turning NNP on at the unit level breaks login on a stock distribution. If you&#8217;ve gone <code>openssh-server-minimal</code> and have no PAM stack to worry about, you can add it back in a 99-*.conf override — that&#8217;s exactly the situation it was designed for.</p>



<h3 class="wp-block-heading">3. An AppArmor profile that&#8217;s actually installed</h3>



<p class="wp-block-paragraph">Debian&#8217;s <code>apparmor-profiles</code> package ships an sshd profile that doesn&#8217;t get loaded. It sits there. It has sat there for years. Our package drops one in <code>/etc/apparmor.d/usr.sbin.sshd</code>, and the postinst calls <code>apparmor_parser -r</code> automatically if AppArmor is active on the host. You install the package, the profile is live. Imagine that.</p>



<p class="wp-block-paragraph">It ships in <strong>complain</strong> mode, deliberately. Anyone who&#8217;s done this before will tell you: AppArmor plus sshd is a tar pit. Every PAM module, every NSS backend (sssd, ldap, winbind), every login shell that might exist on the system needs an explicit allow rule. A profile that&#8217;s even slightly too tight will silently lock you out of your own machine at the worst possible moment, which on a server reachable only by SSH is also the only possible moment.</p>



<p class="wp-block-paragraph">Complain mode logs would-be denials to <code>dmesg</code> and <code>/var/log/audit/audit.log</code> without blocking. Tail them for a few weeks, fix the genuine misses, then promote:</p>



<pre class="wp-block-preformatted"><code>sudo aa-enforce /usr/sbin/sshd
</code></pre>



<h3 class="wp-block-heading">4. A fail2ban jail snippet</h3>



<p class="wp-block-paragraph">We drop <code>/etc/fail2ban/jail.d/myguard-sshd.conf</code>. Backend is systemd-journal — no scraping <code>/var/log/auth.log</code>, no log-rotation games. <code>maxretry=3</code>, <code>findtime=10m</code>, <code>bantime=1h</code>, and bantime-incrementing on repeat offenders up to 30 days. If fail2ban isn&#8217;t installed, the file is inert. <code>openssh-server</code> <code>Recommends:</code> fail2ban, so a default apt install pulls it in. If you&#8217;ve globally disabled Recommends in your apt config, that&#8217;s on you.</p>



<h3 class="wp-block-heading">5. Monthly moduli regeneration</h3>



<p class="wp-block-paragraph">The <code>/etc/ssh/moduli</code> file Debian ships is byte-identical on every install. It was generated once, a long time ago, by someone whose name you can find in <code>git log</code>. We install a <code>ssh-moduli-refresh.timer</code> that runs monthly with a 24-hour randomised delay, regenerates fresh DH moduli at 2048/3072/4096/6144/7680/8192 bits, and atomically swaps them in. The service is heavily sandboxed (idle CPU class, <code>NoNewPrivileges</code>, blank <code>RestrictAddressFamilies=</code>) because <code>ssh-keygen -M generate</code> is CPU-bound for tens of minutes on a small VPS and we&#8217;d rather it didn&#8217;t have the run of the place while it grinds.</p>



<p class="wp-block-paragraph">Yes, you could schedule this yourself with a cron job. You haven&#8217;t, though.</p>



<h3 class="wp-block-heading">6. Compiler flags past <code>hardening=+all</code></h3>



<p class="wp-block-paragraph">Debian&#8217;s <code>hardening=+all</code> gets you PIE, RELRO, stack-protector-strong, FORTIFY=2. That&#8217;s a baseline. Our build appends:</p>



<ul class="wp-block-list">
<li><code>-D_FORTIFY_SOURCE=3</code> — Debian&#8217;s default is still 2; FORTIFY=3 catches a strict superset.</li>
<li><code>-fstack-clash-protection</code> — defuses stack-clash exploits at compile time.</li>
<li><code>-fcf-protection=full</code> on amd64 — Intel CET / shadow stacks. Free CFI on any post-2020 silicon.</li>
<li><code>-mbranch-protection=standard</code> on arm64 — PAC + BTI. Same idea, different ISA.</li>
<li><code>-Wl,-z,now -Wl,-z,relro</code> — full RELRO, no lazy binding. <code>.got.plt</code> isn&#8217;t writable at runtime.</li>
<li><code>-O3 -flto=auto -fno-plt -fno-semantic-interposition -ffunction-sections -fdata-sections -Wl,--gc-sections</code> — speed and dead-code elimination on top of the security. Same binary that protects you better also runs faster.</li>
</ul>



<p class="wp-block-paragraph">None of these flags is exotic. They&#8217;ve all been stable for years. They just haven&#8217;t trickled down into Debian&#8217;s <code>hardening=+all</code> default, which is a process problem rather than a technical one.</p>



<h3 class="wp-block-heading">7. Reproducible builds</h3>



<p class="wp-block-paragraph">Our <code>debian/rules</code> pins <code>SOURCE_DATE_EPOCH</code> from the changelog timestamp when it isn&#8217;t already set. Two builds of the same source produce byte-identical binaries. If a malicious build server ever ships you a tampered <code>sshd</code>, you can prove it.</p>



<h3 class="wp-block-heading">8. Autopkgtests that fail the build</h3>



<p class="wp-block-paragraph">Three tests run on every build, and if any of them fails the package doesn&#8217;t ship:</p>



<ul class="wp-block-list">
<li><strong>sshd-hardened-defaults</strong> — runs <code>sshd -T</code> on the just-built binary, asserts every hardened knob is in effect, fails the build if a deprecated cipher, KEX or MAC ever sneaks back into the active config.</li>
<li><strong>sshd-flavours-exist</strong> — all three flavour packages each ship a working <code>/usr/sbin/sshd</code>.</li>
<li><strong>apparmor-profile-loads</strong> — the profile parses with no warnings and loads cleanly when AppArmor is active.</li>
</ul>



<p class="wp-block-paragraph">Translation: the hardening can&#8217;t quietly regress between releases. The build itself enforces it.</p>



<h2 class="wp-block-heading" id="install">Installing it</h2>



<p class="wp-block-paragraph">If you don&#8217;t have the myguard repo configured yet, follow <a href="https://deb.myguard.nl/how-to-use/">the quick repo setup</a> first. Then pick your flavour:</p>



<pre class="wp-block-preformatted"><code># The default. Use this everywhere except the two edge cases below.
sudo apt install openssh-server

# Active Directory or MIT Kerberos shop.
sudo apt install openssh-server-gssapi

# Bastion jumphost, container, recovery image.
sudo apt install openssh-server-minimal
</code></pre>



<p class="wp-block-paragraph">Whichever you choose, apt pulls in <code>openssh-server-common</code> and lays down the hardened drop-in, the systemd override, the AppArmor profile, the fail2ban jail and the moduli timer. Restart sshd once:</p>



<pre class="wp-block-preformatted"><code>sudo systemctl daemon-reload
sudo systemctl restart ssh
</code></pre>



<p class="wp-block-paragraph">Confirm the live config is the live config:</p>



<pre class="wp-block-preformatted"><code>sudo sshd -T -C user=root | grep -E '^(kex|cipher|mac|passwordauth|x11|permittun)'
</code></pre>



<p class="wp-block-paragraph">If you want to switch flavours later — say, the host gets folded into AD — it&#8217;s one command:</p>



<pre class="wp-block-preformatted"><code>sudo apt install openssh-server-gssapi
</code></pre>



<p class="wp-block-paragraph">The <code>Conflicts:</code> relationship handles the removal. The systemd unit handles the restart. <code>openssh-server-common</code> doesn&#8217;t move, so none of your hardening churns.</p>



<h2 class="wp-block-heading" id="generate-a-key">The 2026 SSH key recipe</h2>



<p class="wp-block-paragraph">If you do nothing else after reading this: stop generating RSA keys. Ed25519 is smaller, faster, has no parameter-generation footguns, isn&#8217;t subject to the SHA-1 deprecation churn, and has been the right answer since 2014. If your <code>~/.ssh/id_rsa</code> is older than your kids, today is a good day.</p>



<h3 class="wp-block-heading">The one-liner</h3>



<pre class="wp-block-preformatted"><code>ssh-keygen -t ed25519 -a 100 -C "$(whoami)@$(hostname)-$(date +%Y%m%d)"
</code></pre>



<ul class="wp-block-list">
<li><code>-t ed25519</code> — modern Edwards-curve signature. 32-byte public key, 64-byte signature, no nonce-reuse class of bug. Boring, which is what you want.</li>
<li><code>-a 100</code> — 100 rounds of the bcrypt-pbkdf KDF on the encrypted private key. An attacker who walks off with your <code>~/.ssh/id_ed25519</code> still has to crack that passphrase, and 100 rounds slows brute-force by orders of magnitude. You pay it once, when ssh-agent unlocks the key.</li>
<li><code>-C "..."</code> — a comment that says which laptop generated it and when. Future you, finding three half-remembered keys in some old <code>authorized_keys</code> file in 2029, will be grateful.</li>
</ul>



<p class="wp-block-paragraph">Pick a real passphrase. &#8220;I want unattended logins&#8221; — that&#8217;s what <code>ssh-agent</code> is for. Unlock the key once, stay unlocked for the session, no typing.</p>



<h3 class="wp-block-heading">Push it to a server</h3>



<pre class="wp-block-preformatted"><code>ssh-copy-id -i ~/.ssh/id_ed25519.pub <span style="display:inline;" class="">&#117;&#115;&#101;&#114;&#64;&#115;&#101;rve&#114;.&#101;xamp&#108;&#101;.&#99;om</span>
</code></pre>



<p class="wp-block-paragraph">With our package, password auth is already off, so the first key has to get there some other way: the cloud provider console, your configuration management, an existing key on a peer host. Once it&#8217;s in:</p>



<pre class="wp-block-preformatted"><code>chmod 700 ~/.ssh
chmod 600 ~/.ssh/authorized_keys
</code></pre>



<p class="wp-block-paragraph">sshd refuses to read group-writable or world-readable key files, by design. That refusal is one of the few things on a hardened sshd that bites people. Now you know.</p>



<h3 class="wp-block-heading">Hardware keys, if you have one</h3>



<p class="wp-block-paragraph">OpenSSH speaks FIDO2 / WebAuthn natively via <code>sk-ssh-ed25519</code>. YubiKey, NitroKey, OnlyKey — anything with the U2F/FIDO2 spec works:</p>



<pre class="wp-block-preformatted"><code>ssh-keygen -t ed25519-sk -O resident -O verify-required \
    -C "yubikey-$(date +%Y%m%d)"
</code></pre>



<ul class="wp-block-list">
<li><code>ed25519-sk</code> — the security-key variant. The private scalar never leaves the token; there is no software-only copy to steal.</li>
<li><code>-O resident</code> — the key handle is stored on the token itself. Pull it back onto a fresh laptop with <code>ssh-keygen -K</code>.</li>
<li><code>-O verify-required</code> — every authentication needs a PIN <em>and</em> a touch. Don&#8217;t skip this one. An attacker with physical access to the token can otherwise log in by tapping it, which is roughly the opposite of what you bought a hardware key for.</li>
</ul>



<p class="wp-block-paragraph"><code>openssh-server</code> and <code>openssh-server-gssapi</code> build with <code>--with-security-key-builtin</code>, so <code>sk-*</code> works out of the box without runtime libfido2. <code>openssh-server-minimal</code> doesn&#8217;t — by design, since minimal is for bastion hosts and containers where the FIDO key lives on the client and the server never needs to know.</p>



<h3 class="wp-block-heading">If you really must use RSA</h3>



<p class="wp-block-paragraph">Sometimes a legacy system insists. Generate at least 4096 bits, and remember that the <em>signature algorithm</em> matters more than the key type — our drop-in already drops <code>ssh-rsa</code> (RSA with SHA-1) and only accepts <code>rsa-sha2-256</code> / <code>rsa-sha2-512</code> from the same RSA key:</p>



<pre class="wp-block-preformatted"><code>ssh-keygen -t rsa -b 4096 -a 100 -C "rsa-$(date +%Y%m%d)"
</code></pre>



<p class="wp-block-paragraph">If a server ever rejects your RSA key with &#8220;no mutual signature algorithm&#8221;, that&#8217;s the diagnosis: your client wants <code>ssh-rsa</code>, the server demands SHA-2. Fix it on the client:</p>



<pre class="wp-block-preformatted"><code># ~/.ssh/config
Host stubborn-old-thing.example.com
    PubkeyAcceptedAlgorithms +rsa-sha2-512,rsa-sha2-256
</code></pre>



<h2 class="wp-block-heading" id="tips-and-tricks">Tips, tricks, and things the Debian default should already do</h2>



<h3 class="wp-block-heading">Use SSH certificates, not <code>authorized_keys</code></h3>



<p class="wp-block-paragraph">Past a handful of users or hosts, copying public keys around becomes an audit nightmare. SSH certificates fix it: a CA signs short-lived user certificates (&#8220;alice can log in as <code>root</code> on hosts matching <code>*.prod.example.com</code> for the next 8 hours&#8221;), and the server only knows the CA&#8217;s public key via <code>TrustedUserCAKeys</code>. Rotation, revocation, audit — all become trivial. Our hardening drop-in already restricts <code>CASignatureAlgorithms</code> to the modern set.</p>



<p class="wp-block-paragraph">Minimal CA bring-up:</p>



<pre class="wp-block-preformatted"><code># on a hardened host (offline-ish, or in a vault)
ssh-keygen -t ed25519 -f ca_user_key -C "user-ca-$(date +%Y%m%d)"

# on every server, in /etc/ssh/sshd_config.d/20-userca.conf
TrustedUserCAKeys /etc/ssh/ca_user_key.pub

# issue an 8-hour certificate for alice
ssh-keygen -s ca_user_key -I alice@laptop -n alice,deploy \
    -V +8h -z 1 ~/.ssh/id_ed25519.pub
</code></pre>



<p class="wp-block-paragraph">The result is a <code>~/.ssh/id_ed25519-cert.pub</code> alongside the user key. ssh presents it automatically. When Bob leaves the team, you don&#8217;t grep two hundred <code>authorized_keys</code> files — you add his cert serial to a <code>RevokedKeys</code> file and push that one file out.</p>



<h3 class="wp-block-heading">Allowlist sources with <code>Match</code></h3>



<p class="wp-block-paragraph">Most servers don&#8217;t need to accept SSH from the entire internet. In <code>/etc/ssh/sshd_config.d/10-source-allowlist.conf</code>:</p>



<pre class="wp-block-preformatted"><code>Match Address !10.0.0.0/8,!192.168.0.0/16,!2001:db8::/32,*
    DenyUsers *
</code></pre>



<p class="wp-block-paragraph">The traditional answer was <code>/etc/hosts.allow</code> with TCP wrappers. We dropped <code>--with-tcp-wrappers</code> on purpose: <code>libwrap</code> has been deprecated for over a decade, upstream OpenSSH removed it in 6.7, and Debian only kept it because backwards compatibility is a religion. <code>Match</code> blocks and nftables do the same job, better.</p>



<h3 class="wp-block-heading">Put SSH into your monitoring</h3>



<p class="wp-block-paragraph">The <code>LogLevel VERBOSE</code> in our drop-in exists for one reason: every successful login records the key fingerprint that authenticated it. That single line is the difference between knowing who got in and guessing. Pipe it to your SIEM:</p>



<pre class="wp-block-preformatted"><code>journalctl -u ssh -f --output=json \
    | jq 'select(.MESSAGE | test("Accepted publickey"))'
</code></pre>



<p class="wp-block-paragraph">Or a quick one-shot review of this week&#8217;s logins:</p>



<pre class="wp-block-preformatted"><code>journalctl -u ssh --since "1 week ago" \
    | grep "Accepted publickey" \
    | awk '{print $9, $11, $13}' | sort | uniq -c | sort -rn
</code></pre>



<h3 class="wp-block-heading">Don&#8217;t move SSH to port 2222</h3>



<p class="wp-block-paragraph">It&#8217;s the oldest folklore recommendation in the book and it hides exactly nothing from a Shodan-scale scan. What actually quietens your logs is per-source rate limiting (which we ship) and fail2ban (which we ship). Keep SSH on 22 and avoid the firewall rule, the monitoring exception, the runbook footnote, and the inevitable &#8220;wait, why can&#8217;t I ssh to that host&#8221; thread on Slack.</p>



<h3 class="wp-block-heading">If you must allow passwords, scope them</h3>



<p class="wp-block-paragraph">A legacy vendor really needs password auth. Fine. Confine it to the VPN:</p>



<pre class="wp-block-preformatted"><code>Match Address 10.99.0.0/24
    PasswordAuthentication yes
    KbdInteractiveAuthentication yes
</code></pre>



<h3 class="wp-block-heading">ProxyJump, not nested ssh</h3>



<pre class="wp-block-preformatted"><code># ~/.ssh/config
Host bastion
    HostName bastion.prod.example.com
    User ops

Host prod-*
    ProxyJump bastion
    User deploy
</code></pre>



<p class="wp-block-paragraph"><code>ssh prod-web-04</code> now does the right thing — single TCP tunnel through the bastion, bastion never holds your private key, no agent forwarding required, no &#8220;ssh-in-ssh-in-ssh&#8221; muscle memory. Pairs naturally with <code>openssh-server-minimal</code> on the bastion: nothing but sshd and authorized_keys, the way bastions were meant to be.</p>



<h3 class="wp-block-heading">Multiplex sessions</h3>



<pre class="wp-block-preformatted"><code># ~/.ssh/config
Host *
    ControlMaster auto
    ControlPath ~/.ssh/cm-%r@%h:%p
    ControlPersist 10m
</code></pre>



<p class="wp-block-paragraph">The second ssh to a host within 10 minutes reuses the first one&#8217;s TCP connection and skips the entire handshake. Ansible runs faster. <code>scp</code> in a loop runs faster. Rapid-fire one-liners run faster. Free.</p>



<h3 class="wp-block-heading">Verify host keys the first time</h3>



<p class="wp-block-paragraph">&#8220;The authenticity of host can&#8217;t be established, are you sure?&#8221; is a security checkpoint, not a nuisance. Get the real fingerprints on the server:</p>



<pre class="wp-block-preformatted"><code>for f in /etc/ssh/ssh_host_*_key.pub; do ssh-keygen -lf "$f"; done
</code></pre>



<p class="wp-block-paragraph">Compare the Ed25519 line to what your client prints. Better: publish them in DNS as <code>SSHFP</code> records and set <code>VerifyHostKeyDNS yes</code> in your client. Our package builds with DNSSEC SSHFP support — Debian&#8217;s <code>dnssec-sshfp.patch</code> is in our applied series.</p>



<h3 class="wp-block-heading">Tune PerSourcePenalties for your traffic shape</h3>



<p class="wp-block-paragraph">Our defaults assume sshd faces the open internet. If yours sits behind a load balancer or a VPN that aggregates many real clients to one source IP, raise the limits or you&#8217;ll tarpit legitimate users:</p>



<pre class="wp-block-preformatted"><code># /etc/ssh/sshd_config.d/50-behind-lb.conf
PerSourceMaxStartups 32
PerSourceNetBlockSize 32:128
</code></pre>



<h3 class="wp-block-heading">Hash your known_hosts</h3>



<p class="wp-block-paragraph">If your laptop gets compromised, an attacker reads <code>~/.ssh/known_hosts</code> and learns every server you&#8217;ve ever connected to. They get a free reconnaissance map. Hash it:</p>



<pre class="wp-block-preformatted"><code>echo 'HashKnownHosts yes' &gt;&gt; ~/.ssh/config
ssh-keygen -H              # rewrites the existing file with hashed entries
</code></pre>



<h3 class="wp-block-heading">Restrict keys with options</h3>



<p class="wp-block-paragraph">Per-key restrictions in <code>authorized_keys</code> tighten things down even further than the global config:</p>



<pre class="wp-block-preformatted"><code># backup user — only from the NAS, only running restic-server, nothing else
restrict,from="10.0.0.42",command="/usr/local/bin/restic-server" ssh-ed25519 AAAAC3Nz... backup@nas
</code></pre>



<p class="wp-block-paragraph"><code>restrict</code> turns off agent forwarding, port forwarding, pty, X11 and user-rc; opt back in explicitly only what you need. <code>from=</code> pins the source IP. <code>command=</code> forces a specific command no matter what the client asks for. Combine all three and an attacker who steals the private key still can&#8217;t do anything but run <code>restic-server</code> from the NAS&#8217;s IP. That&#8217;s the goal.</p>



<h2 class="wp-block-heading" id="when-things-break">When things break</h2>



<h3 class="wp-block-heading">&#8220;My ancient client can&#8217;t connect&#8221;</h3>



<p class="wp-block-paragraph">An OpenSSH from 2014 won&#8217;t negotiate our algorithm list. Three options, ranked:</p>



<ol class="wp-block-list">
<li>Update the client. Almost always the right answer.</li>
<li>Add a <code>Match Address</code> block that relaxes the algorithm set just for that source.</li>
<li>Shadow our drop-in with a 99-*.conf that adds whatever the client needs. Leave a comment that says why.</li>
</ol>



<h3 class="wp-block-heading">&#8220;AppArmor is blocking something&#8221;</h3>



<p class="wp-block-paragraph">The profile ships in complain mode, so this shouldn&#8217;t happen unless you flipped it to enforce. Check <code>dmesg</code> for <code>apparmor="DENIED"</code>, run <code>aa-logprof</code> to generate the missing rules, and drop them in <code>/etc/apparmor.d/local/usr.sbin.sshd</code> rather than editing the shipped profile. Or open an issue against the package — if your stack hits the denial, others&#8217; will too.</p>



<h3 class="wp-block-heading">&#8220;sshd won&#8217;t start after upgrade&#8221;</h3>



<p class="wp-block-paragraph"><code>sudo sshd -t</code> validates the config. <code>journalctl -u ssh -n 50 --no-pager</code> shows the actual error. The usual cause is a leftover file in <code>/etc/ssh/sshd_config.d/</code> from a previous install that references a removed keyword. Our drop-in is <code>00-myguard-hardening.conf</code>; anything with a higher number wins on conflicting keys.</p>



<h3 class="wp-block-heading">&#8220;I installed openssh-server-minimal and pam_systemd broke&#8221;</h3>



<p class="wp-block-paragraph">Working as designed: minimal has no PAM. <code>pam_systemd</code> session registration, <code>pam_limits</code>, motd, Kerberos PAM modules — none of it exists in that binary. If you need any of those, you wanted <code>openssh-server</code>. Minimal is for containers, bastions and recovery images, where having no PAM is the feature.</p>



<h2 class="wp-block-heading" id="why-our-build">Why not just roll your own?</h2>



<p class="wp-block-paragraph">You absolutely can. Everything above is a few hours of work for any competent sysadmin, plus the ongoing cost of keeping it current as OpenSSH ships new algorithms, deprecates old ones, and adds knobs like <code>PerSourcePenalties</code>. We did the work, we keep doing it, and we ship the result as a single <code>apt install</code>. The autopkgtests in our build pipeline make sure the defaults don&#8217;t quietly regress between releases.</p>



<p class="wp-block-paragraph">The packages are out now for Debian bookworm and trixie, Ubuntu noble and resolute. Pull them from <a href="https://deb.myguard.nl/">our repository</a>. Pick the flavour that matches your use case. File issues if you find any. Hardened SSH should have been the default a long time ago — until it is, you can have ours.</p>

]]></content:encoded>
					
		
		
			</item>
	</channel>
</rss>
