From Dropbox to Daily Driver Part 3: Locking Down the Base OS

Part 2 ended with a Raspberry Pi that boots, answers on eth0, and accepts a password-authenticated SSH session. That’s a working Pi, not yet a hardened one. This part closes that gap: real SSH keys generated and installed from both machines used to administer this device, password authentication switched off for good, a firewall that denies everything except SSH, and unattended security updates keeping the base system current without needing to babysit it.

Generating the keys, on the client, not the Pi

The private half of an SSH keypair should never touch the server it’s meant to protect — it belongs on whichever machine is doing the connecting, so that if the Pi is ever compromised down the line, the keys used to reach it were never part of that blast radius. This device gets administered from two different machines day to day, so both got their own keypair:

# On Pro1 (Windows)
ssh-keygen -t ed25519 -C "michealg@pi4-128g"
# On box1 (Linux)
ssh-keygen -t ed25519 -C "michealg@pi4-128g"

Both ran cleanly, each producing its own ed25519 keypair with its own fingerprint. Those fingerprints are permanent identifiers of two real machines this device gets managed from, so — same call as the Pi’s own host key in Part 2 — they’re staying out of this post rather than being published alongside a device that’s about to be reachable from the internet.

Installing the keys, and a real TOFU moment

With a keypair on each machine, the public half needs to land in the Pi’s authorized_keys. box1’s OpenSSH ships ssh-copy-id; Pro1’s Windows OpenSSH client doesn’t, so that one’s a manual pipe:

# box1
ssh-copy-id -i pi4_ed25519.pub michealg@192.168.1.187
# Pro1
ssh michealg@192.168.1.187 "mkdir -p ~/.ssh && chmod 700 ~/.ssh"
Get-Content .\pi4_ed25519.pub | ssh michealg@192.168.1.187 "cat >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys"

box1’s attempt didn’t go smoothly on the first try, and it’s worth walking through exactly why rather than skipping to the fix. ssh-copy-id refused outright:

/usr/bin/ssh-copy-id: ERROR: @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
ERROR: @    WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!     @
ERROR: @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
ERROR: IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY!
ERROR: Someone could be eavesdropping on you right now (man-in-the-middle attack)!
ERROR: Host key verification failed.

That looks alarming, and it’s meant to — but it’s not a false alarm being clicked through, it’s SSH’s trust-on-first-use mechanism working exactly as designed. box1 had a cached host key for this IP address from before this Pi was reflashed, almost certainly left over from the Part 5 dropbox build that ran on the exact same physical device at the exact same address. Reflashing the card generated a brand new host key, so from box1’s point of view, the machine answering at 192.168.1.187 genuinely does look different than the one it talked to last. That’s the correct behaviour, not a bug to work around blindly.

The fix is to clear the specific stale entry, confirm the new key deliberately, and move on:

ssh-keygen -f "/home/michealg/.ssh/known_hosts" -R "192.168.1.187"

Re-running ssh-copy-id after that prompted a fresh TOFU confirmation, same as the first login back in Part 2, and completed normally from there. The lesson isn’t “clear known_hosts whenever this warning shows up” — it’s that the warning is only safe to dismiss when you can actually account for why the identity changed, the way a reflash explains it here. On an unfamiliar network, the same warning deserves the opposite reaction.

Confirming key auth works before touching anything else

Both machines were tested against the Pi with an explicit key before any config changed:

$ ssh -i pi4_ed25519 michealg@192.168.1.187
Linux pi4-128g 6.18.34+rpt-rpi-v8 #1 SMP PREEMPT Debian 1:6.18.34-1+rpt1 (2026-06-09) aarch64
...
michealg@pi4-128g:~ $

Clean connection, no password prompt, from both box1 and Pro1. That’s the checkpoint worth hitting before disabling password auth — verifying the replacement works before removing the fallback, not after.

Disabling password authentication, and a real Debian gotcha

The obvious change:

sudo sed -i 's/^#\?PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config
sudo sed -i 's/^#\?KbdInteractiveAuthentication.*/KbdInteractiveAuthentication no/' /etc/ssh/sshd_config
sudo sshd -t && sudo systemctl restart ssh

sshd -t came back clean, the service restarted without complaint, and testing password auth explicitly against it should have failed:

ssh -o PubkeyAuthentication=no michealg@192.168.1.187

It didn’t. It logged straight in with a password, on a device that had just had PasswordAuthentication no written to its config and a clean config test to prove it.

The cause was a real Debian default, not a mistake in the edit: sshd_config includes Include /etc/ssh/sshd_config.d/*.conf near the top of the file, and sshd applies the first matching directive it finds for any given keyword, not the last — the opposite of how most config formats behave. A drop-in file at /etc/ssh/sshd_config.d/50-cloud-init.conf, written by cloud-init during first boot specifically to guarantee the Imager-configured account could log in, was setting PasswordAuthentication yes and getting read before the edit made further down in the main file ever had a chance to matter.

Editing that file directly would have worked, but it’s owned by cloud-init — a future re-provision could silently regenerate it and quietly undo the hardening. The correct fix uses the same directory, sorted to win instead:

echo "PasswordAuthentication no" | sudo tee /etc/ssh/sshd_config.d/10-harden.conf
sudo sshd -t && sudo systemctl restart ssh

10-harden.conf sorts before 50-cloud-init.conf alphabetically, so it’s read first, and first wins. Testing again from box1:

$ ssh -o PubkeyAuthentication=no michealg@192.168.1.187
michealg@192.168.1.187: Permission denied (publickey).

That’s the actual proof. Anyone following an SSH-hardening guide on Debian or Raspberry Pi OS is worth warning explicitly: check sshd_config.d/ for a cloud-init drop-in before assuming an edit to the main file took effect, because the failure mode here is silent — the service restarts fine, the edit is genuinely in the file, and it simply doesn’t do anything.

Bringing the base system current

Before adding a firewall or automatic updates on top of it, it’s worth starting from a system that isn’t already behind — a fresh image is only as current as the day it was built, and this one had sat for a couple of days between flashing and this part of the series:

sudo apt update && sudo apt upgrade

95 packages upgraded, including a kernel bump from 6.18.34 to 6.18.39 and Debian’s own point release moving from 13.5 to 13.6 mid-series. Nothing dramatic, just the ordinary drift of a few days on a rolling security-updates channel — but it’s the honest starting point for everything below, rather than pretending the image from Part 2 was still current by the time this part got written.

ufw: default deny, one explicit allow

This device’s only intended inbound path is SSH, and even that’s changing shape in Part 5 once Cloudflare Tunnel is back in the picture — a tunnel is outbound-initiated from the Pi’s side, so it never needs an inbound firewall rule of its own. For now, ufw just needs to deny everything by default and explicitly allow the one thing this device is actually meant to answer on:

sudo apt install -y ufw
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow 22/tcp comment 'SSH'
sudo ufw enable
sudo ufw status verbose
Status: active
Default: deny (incoming), allow (outgoing), disabled (routed)
To                         Action      From
--                         ------      ----
22/tcp                     ALLOW IN    Anywhere                   # SSH
22/tcp (v6)                ALLOW IN    Anywhere (v6)              # SSH

Worth being clear that ufw wasn’t already sitting on this image waiting to be enabled — that apt install genuinely pulled it down along with iptables and its supporting libraries as fresh dependencies, not a pre-installed package that just needed switching on.

Unattended upgrades, and checking what it actually covers

“Automatic updates” is easy to enable and easy to leave un-scrutinized. Rather than the interactive dpkg-reconfigure prompt, the config went in directly:

sudo apt install -y unattended-upgrades apt-listchanges
sudo tee /etc/apt/apt.conf.d/20auto-upgrades <<'EOF'
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Download-Upgradeable-Packages "1";
APT::Periodic::AutocleanInterval "7";
APT::Periodic::Unattended-Upgrade "1";
EOF

The part worth actually checking is what it’s going to upgrade unattended. Ubuntu guides usually point at an Allowed-Origins block — that’s Ubuntu’s own naming, not Debian’s, and grepping for it here comes back empty. Debian’s equivalent, and the one that actually exists in /etc/apt/apt.conf.d/50unattended-upgrades on this Trixie install, is Unattended-Upgrade::Origins-Pattern:

Unattended-Upgrade::Origins-Pattern {
        "origin=Debian,codename=${distro_codename},label=Debian";
        "origin=Debian,codename=${distro_codename},label=Debian-Security";
        "origin=Debian,codename=${distro_codename}-security,label=Debian-Security";
//      "origin=Debian,codename=${distro_codename}-updates";
//      "origin=Debian,codename=${distro_codename}-backports";
};

Two things worth knowing from that, not just glancing past it. First, this isn’t a security-only configuration despite “unattended-upgrades” sounding like it should be — the plain label=Debian line means ordinary Trixie package updates are in scope for automatic installation too, not just the security channel. Second, the -updates and -backports origins stay commented out, and Raspberry Pi’s own archive.raspberrypi.com repository isn’t in this list at all — kernel, firmware, and Pi-specific package updates from that source still need the manual apt upgrade run earlier in this part. Automatic updates here means “Debian’s own base system stays current on its own,” not “everything on this device patches itself.” That distinction matters beyond just this repository too — Part 5’s Cloudflare Tunnel client and Part 7’s Wazuh agent both get installed from a downloaded .deb rather than an apt repository at all, which puts them in the same “not actually covered” category as Raspberry Pi’s own packages, worth keeping in mind rather than assuming this section closed the whole patching story.

Fail2ban: considered, and left out on purpose

Most SSH-hardening guides reach for fail2ban around this point, and it’s worth explaining why this build doesn’t, rather than silently skipping it and hoping nobody notices the gap.

Fail2ban’s job is banning an IP after repeated failed password login attempts. Password authentication is already off on this device as of a few steps ago — every unauthorized SSH attempt now fails instantly with Permission denied (publickey), with no password oracle left for fail2ban to meaningfully protect or throttle. And once Part 5 puts this device behind a Cloudflare Zero Trust Access policy, SSH won’t be directly reachable from the internet at all; a connection has to clear that authentication layer before it ever reaches sshd. Adding fail2ban on top of both of those would mean banning IPs for failing a login method that was never going to succeed regardless of how many times it was retried.

Where fail2ban would normally earn its place — knowing that something is repeatedly hammering the port, rather than just silently dropping it — is exactly what Parts 6 and 7 build with Wazuh: a real, searchable alert instead of a quiet iptables ban nobody ever sees. That’s a deliberate substitution, not an oversight, and it’s the kind of decision this series would rather make out loud than leave unexplained.

What’s next

This Pi now authenticates by key only, denies every inbound connection except SSH, and keeps its base packages current without manual intervention. Part 4 builds on the key-only login this part just locked in: adding TOTP-based two-factor authentication on top of it, so a compromised key alone still isn’t enough to get in.