This scheme uses two devices on a local network:
- key — a small always-on device that holds half the unlock credential and broadcasts a private Wi-Fi hotspot
- vault — the main storage device with a LUKS-encrypted partition that auto-unlocks at boot by reaching the key over the hotspot, then switches to your home Wi-Fi
The full unlock credential is derived by combining the vault’s CPU serial with the key’s CPU serial + stored passphrase. Neither device alone holds the complete key.
Hardware
I devised this scheme using the following hardware. Your mileage may vary with others.
| Component | Role | Notes |
|---|---|---|
| Raspberry Pi 4B | Vault | Main storage device |
| Raspberry Pi Zero 2 | Key | Always-on unlock device |
| 128 GB micro-SD card. | Vault storage | Raspberry Pi OS Lite Trixie |
| 16 GB micro-SD card | Key storage | Raspberry Pi OS Lite Trixie |
| USB-A stick | Vault boot | Ubuntu flashed via Raspberry Pi Imager |
Phase 1 — Key device setup
Prepare the key device: harden it, create the service account the vault will SSH into, and enable temporary password auth so the vault can push its public key.
sudo apt update
sudo apt full-upgrade -y
sudo apt install ufw fail2ban screen -y
sudo adduser --disabled-password --gecos "" unlocker && \
echo "unlocker:password" | sudo chpasswd
sudo ufw default allow outgoing
sudo ufw default deny incoming
sudo ufw allow ssh
sudo ufw limit ssh/tcp
sudo ufw --force enable
sudo cp /etc/fail2ban/jail.conf /etc/fail2ban/jail.local
sudo sed 's/PasswordAuthentication no/PasswordAuthentication yes/g' \
/etc/ssh/sshd_config.d/50-cloud-init.conf \
| sudo tee /etc/ssh/sshd_config.d/50-cloud-init.conf && \
sudo systemctl restart ssh
# we'll be using screen in the next section related to the key
screen -q
Phase 2 — Vault: initial boot
On first boot, open crontab once to initialize cron infrastructure, then shut down cleanly.
crontab -e # open once to initialize — do NOT save a job
sudo shutdown -h now
Phase 3 — Vault: partition resize and LUKS setup
This script shrinks the root filesystem to 8 GB, creates a new ~60 GB LUKS partition, formats it, then reboots. Run from recovery or initramfs — the root filesystem must not be mounted.
cat << 'EOF' > resize_and_luks.sh
#!/bin/bash
set -Eeuo pipefail
trap 'echo "Failed at line $LINENO"; exit 1' ERR
sudo e2fsck -fp /dev/mmcblk0p2
sudo resize2fs /dev/mmcblk0p2 8G
sudo parted /dev/mmcblk0 resizepart 2 60GB
sudo resize2fs /dev/mmcblk0p2
sudo e2fsck -f /dev/mmcblk0p2
sudo parted /dev/mmcblk0 mkpart primary ext4 64GB 124GB
echo "Starting LUKS encryption. Please follow the prompts below:"
sudo cryptsetup luksFormat /dev/mmcblk0p3 && \
sudo cryptsetup open /dev/mmcblk0p3 encrypted_data && \
sudo mkfs.ext4 /dev/mapper/encrypted_data && \
sudo cryptsetup close encrypted_data
EOF
chmod +x resize_and_luks.sh
./resize_and_luks.sh && \
rm ./resize_and_luks.sh && \
echo "Process complete. Rebooting..." && \
sleep 3 && \
sudo reboot
Phase 4 — Vault: full provisioning
Back on the vault. This script installs Tailscale, configures the firewall, generates the service SSH keypair, pushes the public key to the key device, derives the two-part unlock credential, adds a second LUKS key slot, writes the unlock-and-start.sh and lock-and-stop.sh helpers, installs systemd services for shutdown-time locking, and configures cron to auto-unlock on boot.
cat >./setup.sh <<'SCRIPT_EOF'
#!/usr/bin/env bash
set -Eeuo pipefail
on_err() {
local exit_code=$?
echo
echo "FAILED"
echo "Line: $1"
echo "Command: $2"
echo "Exit code: $exit_code"
echo "Script kept at: $0"
exit "$exit_code"
}
trap 'on_err $LINENO "$BASH_COMMAND"' ERR
read -r -s -p "LUKS Service passphrase: " PW
echo
sudo apt update
sudo apt full-upgrade -y
sudo apt autoremove --purge -y
sudo apt autoclean -y
sudo apt install cryptsetup ufw fail2ban screen sshpass -y
curl -fsSL https://tailscale.com/install.sh | sh
sudo tailscale set --operator="$USER"
sudo ufw default allow outgoing
sudo ufw default deny incoming
sudo ufw allow ssh
sudo ufw limit ssh/tcp
sudo ufw allow in on tailscale0
sudo ufw --force enable
sudo cp /etc/fail2ban/jail.conf /etc/fail2ban/jail.local
cat <<'EOF' | sudo tee -a /etc/fail2ban/jail.local >/dev/null
[sshd]
enabled = true
port = ssh
filter = sshd
backend = systemd
maxretry = 6
EOF
sudo mkdir -p /mnt/secure
sudo chown -R "$USER:$USER" /mnt/secure
set +o history
ssh-keygen -t ed25519 -C "service-account-unlocker" -f ~/.ssh/id_ed25519 -N ""
sshpass -p 'password' ssh-copy-id -o StrictHostKeyChecking=accept-new unlocker@keyhostname.local
REMOTE_KEY="$(
ssh -q -o ConnectTimeout=20 unlocker@keyhostname.local \
"printf '%s' '$PW' > .key && tr -d '\n' < .key && grep Serial /proc/cpuinfo | cut -d ' ' -f 2"
)"
CPU="$(grep Serial /proc/cpuinfo | cut -d ' ' -f 2)"
FULL_KEY="${CPU}${REMOTE_KEY}"
echo "passcode: $FULL_KEY"
echo "Adding service key (second LUKS key slot)..."
sudo cryptsetup luksAddKey /dev/mmcblk0p3
sudo tee /usr/local/bin/unlock-and-start.sh >/dev/null <<'EOF'
#!/usr/bin/env bash
set -Eeuo pipefail
REMOTE_KEY="$(
ssh -q -o ConnectTimeout=20 unlocker@keyhostname.local \
"tr -d '\n' < .key && grep Serial /proc/cpuinfo | cut -d ' ' -f 2"
)"
if [ -z "$REMOTE_KEY" ]; then
echo "Connection failed or Serial empty. Aborting."
exit 1
fi
CPU="$(grep Serial /proc/cpuinfo | cut -d ' ' -f 2)"
FULL_KEY="${CPU}${REMOTE_KEY}"
echo "Decrypting LUKS partition..."
printf '%s' "$FULL_KEY" | sudo cryptsetup open /dev/mmcblk0p3 secure_drive --key-file -
if [ -d "/mnt/secure" ]; then
sudo mount /dev/mapper/secure_drive /mnt/secure
echo "Partition mounted at /mnt/secure"
else
echo "Error: /mnt/secure directory does not exist. Aborting."
exit 1
fi
nmcli connection down "ciphernet"
nmcli connection modify "netplan-wlan0-homewifi" connection.metered yes connection.autoconnect yes
nmcli connection up "netplan-wlan0-homewifi"
tailscale up
echo "vault unlocked and online."
EOF
sudo chmod +x /usr/local/bin/unlock-and-start.sh
cat <<'EOF' | sudo tee /etc/sudoers.d/username-unlock >/dev/null
username ALL=(ALL) NOPASSWD: /usr/sbin/cryptsetup open /dev/mmcblk0p3 *, /usr/bin/mount /dev/mapper/* /mnt/secure, /usr/bin/tailscale up *, /usr/bin/nmcli *
EOF
sudo chmod 440 /etc/sudoers.d/username-unlock
sudo visudo -cf /etc/sudoers.d/username-unlock
sudo cryptsetup open /dev/mmcblk0p3 secure_drive
sudo mount /dev/mapper/secure_drive /mnt/secure
sudo chown -R "$USER:$USER" /mnt/secure
sudo umount /mnt/secure
sudo cryptsetup close secure_drive
sudo tee /usr/local/bin/lock-and-stop.sh >/dev/null <<'EOF'
#!/usr/bin/env bash
set -Eeuo pipefail
echo "Unmounting /mnt/secure..."
sudo umount /mnt/secure
echo "Closing LUKS partition..."
sudo cryptsetup close secure_drive
echo "System locked successfully."
EOF
sudo chmod +x /usr/local/bin/lock-and-stop.sh
sudo tee /etc/systemd/system/luks-halt.service >/dev/null <<'EOF'
[Unit]
Description=Cleanly stop apps, unmount, and close LUKS before system halt
DefaultDependencies=no
Before=shutdown.target reboot.target halt.target
[Service]
Type=oneshot
ExecStart=/usr/local/bin/lock-and-stop.sh
RemainAfterExit=yes
[Install]
WantedBy=halt.target reboot.target shutdown.target
EOF
sudo systemctl enable luks-halt.service
sudo tee /usr/local/bin/boot.sh >/dev/null <<'EOF'
#!/usr/bin/env bash
set -Eeuo pipefail
sudo nmcli connection down "netplan-wlan0-homewifi"
sudo nmcli connection modify "netplan-wlan0-homewifi" \
connection.metered no \
connection.autoconnect no
sudo nmcli connection modify "ciphernet" \
connection.autoconnect no
sudo nmcli connection up "ciphernet"
EOF
sudo chmod +x /usr/local/bin/boot.sh
sudo tee /etc/systemd/system/boot-connection.service >/dev/null <<'EOF'
[Unit]
Description=Set NetworkManager connection properties
After=network.target NetworkManager.service
Wants=NetworkManager.service
[Service]
Type=oneshot
ExecStart=/usr/local/bin/boot.sh
RemainAfterExit=yes
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl enable boot-connection.service
echo '*/2 * * * * /usr/local/bin/unlock-and-start.sh >> /tmp/unlock.log 2>&1' | crontab -
sudo tailscale up
SCRIPT_EOF
chmod +x ./setup.sh
echo "Running ./setup.sh"
sudo bash ./setup.sh && \
rm ./setup.sh && \
echo "Success. Temp script removed." && \
# we'll use screen for the next vault step
screen -q
Phase 5 — Key: lock down SSH and create hotspot
Disables password SSH, restricts the unlocker account to key-only auth, and creates the ciphernet Wi-Fi hotspot.
sudo sed 's/PasswordAuthentication yes/PasswordAuthentication no/g' \
/etc/ssh/sshd_config.d/50-cloud-init.conf \
| sudo tee /etc/ssh/sshd_config.d/50-cloud-init.conf && \
sudo tee /etc/ssh/sshd_config > /dev/null << 'EOF'
Match User unlocker
PasswordAuthentication no
AuthenticationMethods publickey
AllowTcpForwarding no
X11Forwarding no
EOF
sudo systemctl restart ssh
sudo nmcli connection add con-name ciphernet \
type wifi \
ifname wlan0 \
ssid ciphernet \
802-11-wireless.mode ap \
802-11-wireless.band bg \
wifi-sec.pairwise ccmp \
wifi-sec.proto rsn \
wifi-sec.key-mgmt wpa-psk \
wifi-sec.psk secure-wifi-pw \
ipv4.method shared \
ipv4.address 192.168.7.1/24 \
autoconnect yes
sudo nmcli connection delete "netplan-wlan0-homewifi"
sudo nmcli connection up ciphernet
Phase 6 — Vault: connect to ciphernet hotspot
On the vault, wait for the key’s hotspot to appear (re-run the rescan line every minute or so), then lock in the static IP and reboot.
sudo nmcli dev wifi rescan
sudo nmcli connection modify "netplan-wlan0-homewifi" \
connection.metered no \
connection.autoconnect no
sudo nmcli connection add type wifi \
ifname wlan0 \
con-name ciphernet \
ssid ciphernet
sudo nmcli connection modify "ciphernet" \
wifi-sec.key-mgmt wpa-psk \
wifi-sec.psk sisima18 \
ipv4.method manual \
ipv4.address 192.168.7.3/24 \
connection.autoconnect yes
sudo reboot