Before locking down a desktop, it's worth asking what you're actually defending against.

  1. Supply chain attacks — malicious packages or dependency confusion
  2. Prompt injection — agentic AI reading poisoned context (code, web, documents)
  3. Credential theft — API keys, SSH keys, cloud credentials
  4. Network surveillance — DNS hijacking, ISP logging, MITM on public Wi-Fi
  5. Physical theft — laptop stolen with unlocked secrets
  6. Data exfiltration — malicious or compromised processes sending data outbound

The defensive posture is defence-in-depth: multiple independent layers so that a failure in one is caught by another.

┌─────────────────────────────────────────────────────────┐
│  Layer 1: Boot security (Secure Boot + systemd-boot)     │
├─────────────────────────────────────────────────────────┤
│  Layer 2: Encrypted secrets (sops-nix + age)             │
├─────────────────────────────────────────────────────────┤
│  Layer 3: Encrypted DNS (Unbound + NextDNS DoT)          │
├─────────────────────────────────────────────────────────┤
│  Layer 4: Application firewall (opensnitch)              │
├─────────────────────────────────────────────────────────┤
│  Layer 5: Zero-trust networking (Tailscale)              │
├─────────────────────────────────────────────────────────┤
│  Layer 6: Browser security (extensions + policies)       │
├─────────────────────────────────────────────────────────┤
│  Layer 7: Credential hygiene (KeePassXC + GPG)           │
├─────────────────────────────────────────────────────────┤
│  Layer 8: Backup (encrypted restic + rclone)             │
└─────────────────────────────────────────────────────────┘

Layer 1: Boot security

The NixOS config uses systemd-boot with a boot partition signed via Secure Boot. The kernel and initrd are managed, version-controlled derivations. Boot generations are limited to 10 to prevent partition exhaustion:

boot.loader.systemd-boot.enable = true;
boot.loader.systemd-boot.configurationLimit = 10;
boot.loader.efi.canTouchEfiVariables = true;

Layer 2: Encrypted secrets with sops-nix

The weakest link in most dotfile repos is plaintext secrets. API keys, cloud credentials, and backup passwords inevitably end up in a secrets/ directory, or worse, in a config file that gets committed.

The solution is sops-nix: an encrypted secrets format using age (the modern GPG alternative). Secrets are encrypted at rest in the repo and decrypted at build time.

# nix/home/default.nix
sops = {
  defaultSopsFile = ../../secrets/opencode.yaml;
  age.keyFile = "${config.home.homeDirectory}/.config/sops/age/keys.txt";

  secrets = {
    context7_api_key = { };
    deepseek_api_key = { };
    openrouter_api_key = { };
  };
};

The workflow

# One-time: generate an age key
age-keygen -o ~/.config/sops/age/keys.txt

# Edit secrets (opens $EDITOR with decrypted content)
sops ~/dotfiles/secrets/opencode.yaml

# The encrypted file is committed to git:
#    age:enc:...  (unreadable without the key)

At home-manager switch, sops-nix decrypts the secrets and places them at $SOPS_PATH/<secret-name>: world-unreadable files that only the user can read. A fish conf.d snippet loads them as environment variables:

# Generated by sops-nix — do not edit
if test -f /run/secrets/context7_api_key
  set -gx CONTEXT7_API_KEY (string trim < /run/secrets/context7_api_key)
end

The result: secrets are committed to the repo (encrypted), decrypted at build time rather than runtime, never in environment variables of child processes unless explicitly loaded, and rotated by re-encrypting a single file.

Backup password migration

The restic backup passwords are still plaintext ~/.restic/p.txt (with chmod 600). The migration path is documented in backup.nix:

# CURRENT:  ~/.restic/p.txt — plaintext, chmod 600
# TARGET:   sops-nix encrypted secrets

Once the restic repositories are re-initialised with new passwords, the plaintext files disappear.

Layer 3: Encrypted DNS with Unbound + NextDNS

Every DNS query is encrypted via DNS-over-TLS to NextDNS. The local resolver runs as a hardened Unbound instance:

services.unbound = {
  enable = true;
  settings = {
    server = {
      interface = [ "127.0.0.1" "::1" ];
      access-control = [ "127.0.0.0/8 allow" "::1/128 allow" ];
      hide-identity = true;
      hide-version = true;
      harden-glue = true;
      harden-dnssec-stripped = true;
    };
    forward-zone = [{
      name = ".";
      forward-tls-upstream = "yes";
      forward-addr = [
        "45.90.28.0@853#usman-ca9fb1.dns.nextdns.io"
      ];
    }];
  };
};

When AI agents fetch web content or API documentation, their DNS queries are encrypted and filtered by NextDNS. Malicious domains, known C2 endpoints, and tracking domains are blocked at the DNS layer before any agent code touches them.

NextDNS provides per-device analytics, blocklists (OISD, Threat Intelligence Feeds), and native ad/tracker blocking — no browser extension needed at the network level.

Layer 4: Application firewall with opensnitch

opensnitch is an interactive application firewall (the spiritual successor to Little Snitch on macOS). Every new outbound connection triggers a dialog:

services.opensnitch.enable = true;

The rules accumulate in ~/.config/opensnitch/ and persist across reboots. If curl suddenly tries to connect to a new IP, you see it. If a compromised npm package tries to phone home, you see it. If an agent process makes unexpected outbound connections, you see it.

Layer 5: Zero-trust networking with Tailscale

Tailscale provides a WireGuard-based mesh VPN with built-in SSO:

services.tailscale = {
  enable = true;
  openFirewall = true;
  useRoutingFeatures = "both";
};

All inter-machine traffic goes through Tailscale: encrypted, authenticated, and authorised by Tailscale ACLs. This replaces traditional VPNs (no OpenVPN configs), port forwarding (machines reach each other by Tailscale IP/hostname), and SSH bastion hosts (SSH via Tailscale is direct, with optional SSH CA).

Layer 6: Browser security

The browser is the largest attack surface on any desktop. The Zen Browser config in browser.nix enforces:

Privacy extensions (force-installed via Firefox policies)

ublock-origin              # Comprehensive ad/tracker/malware blocking
clearurls                  # Strips tracking parameters from URLs
decentraleyes              # Local CDN replacement
dont-track-me-google       # Removes Google's tracking redirects
facebook-container         # Isolates Facebook in a container tab
cookie-autodelete          # Auto-deletes cookies when tab closes
umatrix                    # Per-site request control
netcraft                   # Anti-phishing
terms-of-service-didnt-read

Security policies

extraPolicies = {
  DisableAppUpdate = true;
  Proxy = {
    ConnectionType = "pac";
    AutoConfigURL  = "file://${HOME}/.config/ginmon/proxy.pac";
  };
};

The proxy PAC file routes *.ginmon-internal.com through a SOCKS5 proxy while everything else goes direct. No full-tunnel VPN needed for work.

Layer 7: Credential hygiene

KeePassXC

The password manager runs as a Wayland-native app with a Hyprland window rule:

hl.window_rule({ match = { class = "keepassxc" }, float = true });
hl.window_rule({ match = { class = "keepassxc" }, size  = "900 600" });

KeePassXC auto-type, browser integration via the KeePassXC-Browser extension, and the CLI (keepassxc-cli) all provide credential access without passwords in plaintext.

GPG with paper backup

home.packages = with pkgs; [
  paperkey     # Print GPG key as paper backup
];

The paperkey tool exports the secret key material as a machine-readable paper backup. Combined with the GPG key backup to encrypted restic, this gives two independent recovery paths.

SSH key management

SSH keys are managed via programs.ssh.matchBlocks in home-manager:

programs.ssh.matchBlocks."hermes-vm" = {
  hostname = "192.168.100.2";
  user = "hermes";
  identityFile = "~/.ssh/id_ed25519";
};

No SSH config file to manage. Match blocks for every host (GitHub, git worktrees, Hermes VM, Ubuntu VPN VM, Tailscale nodes) are declared once and applied everywhere.

Layer 8: Encrypted backups

The backup strategy has three tiers.

1. Local (Seagate external drive: daily config backup, weekly bulk)

systemd.user.services.restic-backup-configs = {
  description = "Restic: back up configs + secrets to Seagate";
  # ~/.aws ~/.gnupg ~/.ssh ~/dotfiles ~/gpg-backup
};

systemd.user.services.restic-backup-bulk = {
  description = "Restic: back up media + projects to Seagate";
  # ~/Dev ~/Documents ~/Downloads ~/IdeaProjects ~/Pictures ~/Videos
};

2. Google Drive — encrypted (sensitive docs)

systemd.user.services.restic-backup-gdrive-sensitive = {
  # ~/.ssh ~/.gnupg ~/.aws ~/dotfiles ~/gpg-backup
  # ~/Documents/personal ~/Documents/secure
  # → encrypted restic blobs → Google Drive
};

3. Google Drive — plain (for accessibility)

systemd.user.services.rclone-sync-gdrive = {
  # ~/Documents/personal ~/Dev/notes
  # → real files, browsable in Google Drive
};

The split matters. Sensitive documents (IDs, payslips, keys) are encrypted into restic blobs, unreadable by Google. Non-sensitive content (reading notes, guides) syncs as real files for the Google Drive experience.

All backup services run as systemd --user timers with Nice=15 and IOSchedulingClass=idle. They don't compete with foreground work.

How sops-nix integrates with agent secrets

The intersection of security and agentic AI is secrets management. The agent config references API keys as ${VARIABLE} placeholders, and the fish environment loads them at login:

sops-nix decrypts       →  /run/secrets/deepseek_api_key
fish conf.d snippet     →  set -gx DEEPSEEK_API_KEY (... read from path)
opencode.jsonc          →  "${DEEPSEEK_API_KEY}"  →  runtime interpolation

The agent process never knows where the key came from. It only sees an environment variable. Compromising the agent process doesn't expose the encrypted file or the age key (permissions prevent it). Key rotation is one sops edit and one home-manager switch.

In Part 4, we'll take this much further: running agents in gVisor sandboxes with zero secrets, mediated by an audited MCP broker on the host.