home / security stack
Personal Security Stack
A detailed breakdown of how my own machines, browsers, mail and research environments are set up, with configs. Secrets are redacted. It describes my setup; it is not a recommendation for yours.
Threat model
who I defend against- in scope
- Opportunistic malware and phishing, credential theft, theft or loss of the laptop, network observers on untrusted Wi-Fi, and mistakes that link my research identity to my daily one.
- out of scope
- A well-resourced targeted adversary and covert physical access. I reduce exposure to these but do not claim to defend against them.
- known gaps
- No measured or verified boot of the initramfs. Browsers and the VPN app are third-party binaries I do not build myself. Local detection is logs I read myself, not a staffed monitoring service.
Overview
trust zonesOperating system
Void Linux musl, minimalDaily driver is a minimal Void Linux (musl) install: runit for services, xbps for packages, dwm as the window manager and st as the terminal. No display manager and no desktop environment, so far fewer services and listening sockets than a general-purpose desktop. musl is a smaller codebase than glibc, but that is not a security mitigation in itself and I do not treat it as one.
Brave, Mullvad Browser and the Mullvad app ship as glibc binaries, so they run from a separate glibc Void chroot rather than on the musl host.
Hardening
- Install only what I use;
xbps-query -mreviewed monthly and unused packages removed. - Updates weekly with
xbps-install -Su, then reboot if the kernel changed. doasinstead of sudo, withpermit persistfor the admin group only; no root login.- Default-deny inbound firewall with nftables (below).
- Kernel and network sysctls tightened (below).
- Nothing listens beyond loopback; checked with
ss -tulpnafter changes.
# /etc/sysctl.d/99-hardening.conf kernel.kptr_restrict = 2 kernel.dmesg_restrict = 1 kernel.yama.ptrace_scope = 2 kernel.unprivileged_bpf_disabled = 1 net.core.bpf_jit_harden = 2 fs.protected_symlinks = 1 fs.protected_hardlinks = 1 fs.protected_fifos = 2 fs.protected_regular = 2 net.ipv4.tcp_syncookies = 1 net.ipv4.conf.all.rp_filter = 1 net.ipv4.conf.all.accept_redirects = 0 net.ipv6.conf.all.accept_redirects = 0 net.ipv4.conf.all.send_redirects = 0 net.ipv4.conf.all.accept_source_route = 0
# /etc/nftables.conf (loaded from /etc/rc.local)
table inet filter {
chain input {
type filter hook input priority 0; policy drop;
ct state established,related accept
iif lo accept
ip protocol icmp icmp type { echo-request } limit rate 5/second accept
ip6 nexthdr icmpv6 accept
}
chain forward { # research VMs: out via the VPN only, never to the LAN
type filter hook forward priority 0; policy drop;
ct state established,related accept
iifname "br-lab" oifname "wg-lab" accept
}
chain output { type filter hook output priority 0; policy accept; }
}Disk encryption and backups
LUKS2 and resticRoot is a LUKS2 volume with a long passphrase; only the EFI partition is unencrypted. I shut down rather than suspend when the laptop leaves my sight. Backups follow 3-2-1: the machine, an encrypted external drive, and one offline copy, with restic encrypting every snapshot on the client. I test a restore every quarter, because an untested backup is a hope, not a backup.
# restic to an encrypted external drive; second copy kept offline restic -r /mnt/backup/restic backup ~ \ --exclude ~/.cache --exclude ~/vm --exclude-caches restic -r /mnt/backup/restic forget --keep-daily 7 --keep-weekly 4 --keep-monthly 12 --prune restic -r /mnt/backup/restic check --read-data-subset=10%
Browsers, passwords and 2FA
separate roles, separate profilesBrave (hardened) is for logged-in daily use. Managed policy disables Rewards, Wallet, VPN, AI chat, sync and the built-in password manager, and blocks every extension, so the attack surface is Brave itself plus its Shields.
Mullvad Browser is for research browsing. It standardises its fingerprint so I look like every other Mullvad Browser user, and ships with uBlock Origin. It never logs into anything tied to me.
Neither browser has Firefox-style containers, so isolation comes from a different browser, profile and OS user per role, not from tabs in one profile.
Passwords and 2FA
- Passwords live in KeePassXC: a local database with a long passphrase, no browser sync, backed up with everything else.
- Two FIDO2 hardware keys, one in daily use and one stored offline, registered everywhere that supports them.
- TOTP only where hardware keys are unsupported, with codes kept apart from the password for that account.
- Recovery codes printed and stored offline.
// /etc/brave/policies/managed/hardening.json
{
"BraveRewardsDisabled": true,
"BraveWalletDisabled": true,
"BraveVPNDisabled": true,
"BraveAIChatEnabled": false,
"SyncDisabled": true,
"PasswordManagerEnabled": false,
"MetricsReportingEnabled": false,
"SearchSuggestEnabled": false,
"BlockThirdPartyCookies": true,
"WebRtcIPHandling": "disable_non_proxied_udp",
"DnsOverHttpsMode": "off",
"ExtensionInstallBlocklist": ["*"]
}VPNs and network
Mullvad and ProtonVPNI use Mullvad VPN as the default and ProtonVPN as a second provider, so research traffic and daily traffic need not share an exit or an account. Both run as plain WireGuard interfaces (wg-quick) rather than GUI apps, which keeps the client small and easy to audit. A VPN moves trust from my ISP to the provider; it does not make me anonymous.
# /etc/wireguard/wg-lab.conf (Mullvad WireGuard, no GUI app needed) [Interface] PrivateKey = <redacted> Address = 10.x.x.x/32 DNS = 10.64.0.1 [Peer] PublicKey = <redacted> Endpoint = <redacted>:51820 AllowedIPs = 0.0.0.0/0, ::0/0
Mail is Proton Mail, with login protected by a hardware key. People can encrypt to my published PGP key (see the secure contact ritual) and filters sort disclosure mail by subject prefix. The body is protected; subject lines and metadata are not, which is why I ask for generic subjects.
# Proton Mail filter (Sieve): route disclosure mail to its own folder
require ["fileinto"];
if header :contains "subject" ["[REPORT]", "[SETUP-TEST]"] { fileinto "Reports"; }
elsif header :contains "subject" "[COLLAB]" { fileinto "Collab"; }Isolating research
VMs, accounts and segmentationResearch never happens in my daily session. Anything I run or poke at goes in a disposable KVM/QEMU guest on its own bridge (br-lab). The firewall forwards that bridge to the VPN interface only, so a compromised guest cannot reach my LAN or the host's services.
- Separate OS users for daily, research and admin; no shared home directories.
- Each guest is a throwaway overlay on a read-only base image, deleted when done.
- No shared folders, no clipboard sharing, no USB passthrough by default.
- No real credentials or client data inside any guest. Write-ups follow the sanitization policy.
# throwaway research VM: disposable overlay on a read-only base image qemu-img create -f qcow2 -b base-void.qcow2 -F qcow2 run-$(date +%F).qcow2 qemu-system-x86_64 -enable-kvm -m 4G -smp 2 \ -drive file=run-$(date +%F).qcow2,if=virtio \ -nic bridge,br=br-lab,model=virtio-net-pci \ -display gtk,gl=off # no shared folders, no clipboard sharing # done? delete the overlay; the base image is never written to rm run-*.qcow2
Firmware and physical access
the layer below the OSEncryption protects a lost laptop only if the boot path cannot be tampered with, so the firmware settings matter as much as the disk passphrase. I set an administrator password in UEFI, disable booting from removable media, and keep boot order fixed. Fast boot and wake-from-USB are off, so I see the boot media change before the OS starts.
Removable media is treated as untrusted and off by default. I do not allow autorun anywhere, and a drive I did not buy myself is only ever read in the research VMs. The webcam has a physical cover. Anything that needs stronger separation than an encrypted volume and a hardened boot path does not live on this machine.
- UEFI admin password set, external boot disabled, boot order pinned.
- BIOS/firmware updates applied from the vendor after checking the release notes.
- Removable media off by default, no autorun; unknown drives only in research VMs.
- Theft and loss are handled by LUKS2 plus shutdown-when-unattended, not by relying on suspend.
Detection on my own machines
notice when the boundary is touchedHardening is half of it; I also want to know when something crosses a line. The host keeps a small, focused set of logs that survive a reboot, and auditd watches the paths that matter: privilege and access-control files, SSH keys, cron and the bootloader. Authentication failures are rate-limited, and integrity is checked against a baseline kept off the machine.
# /etc/audit/rules.d/host.rules (excerpt) -w /etc/doas.conf -p wa -k priv_esc -w /root/.ssh/authorized_keys -p wa -k ssh_keys -w /etc/cron.d -p wa -k cron -w /boot -p wa -k boot -a always,exit -F arch=b64 -S execve -k exec # the same watches /detections/ documents for the lab apply here
- auditd watches privilege, key, cron and boot paths; alerts land in my mailbox.
- Auth failures are rate-limited and logged, not silently dropped.
- A file-integrity baseline is stored off the host, so a change can be spotted after the fact.
- The rules are the same shape as the ones in the detection library, tested in the lab first.
Verification and maintenance
what I check, and how often| Cadence | Check |
|---|---|
| Weekly | Apply updates (xbps-install -Su) and reboot if the kernel changed; confirm nothing new is listening with ss -tulpn. |
| Monthly | Review installed packages against what I actually use (xbps-query -m); re-read the nftables rules and the browser managed policy. |
| Quarterly | Test a full restore from a restic snapshot; verify the offline FIDO2 key still works; rotate long-lived secrets and the WireGuard keys. |
| After any change | Re-run the listener and firewall checks; re-test the VPN kill-switch; confirm auditd rules still load. |
What is redacted
Keys, endpoints, addresses and usernames are replaced with placeholders. Snippets are excerpts, not complete files, and will change as my setup does.