ssd.nix (9150B)
1 # modules/hosts/laptop/ssd.nix 2 # 3 # Spare Samsung SSD 980 1 TB (LUKS2, btrfs label SSD) at /mnt/ssd, unlocked at 4 # boot from /etc/secrets/ssd.key. The key is restored by hand once (vault 5 # samsung-ssd.md, section 2): 6 # 7 # sudo mkdir -p -m 700 /etc/secrets 8 # gpg -d --pinentry-mode loopback ssd.key.gpg | sudo tee /etc/secrets/ssd.key >/dev/null 9 # sudo chmod 400 /etc/secrets/ssd.key 10 # 11 # `nofail` on both lines: the laptop boots normally while the key (or the 12 # drive) is missing; the unit just fails. Swap the key path for `none` to type 13 # the passphrase at boot instead. 14 { ... }: 15 { 16 flake.nixosModules.laptop-ssd = 17 { pkgs, user, ... }: 18 let 19 vault = pkgs.writeShellApplication { 20 name = "secure-vault"; 21 runtimeInputs = with pkgs; [ cryptsetup util-linux coreutils e2fsprogs psmisc gawk sops ]; 22 text = '' 23 export LC_ALL=C 24 case "''${1:-help}" in 25 init|open|lock|status|busy|backup-header) ;; 26 *) echo 'Usage: secure-vault {init|open|lock|status|busy|backup-header DEST.sops.json}'; exit 0 ;; 27 esac 28 if (( EUID != 0 )); then 29 exec /run/wrappers/bin/sudo "$0" "$@" 30 fi 31 image=/mnt/ssd/.secure-vault/vault.luks 32 pending=/mnt/ssd/.secure-vault/vault.luks.building 33 mapper=netrunner-private 34 target=/mnt/vault 35 owner=${user} 36 fail() { echo "$*" >&2; exit 1; } 37 # Serialize lifecycle operations. Nothing here manages the outer ssd mapper. 38 exec 9>/run/lock/secure-vault.lock 39 flock -n 9 || fail 'Another vault operation is running.' 40 require_ssd() { 41 mountpoint -q /mnt/ssd || fail 'The existing SSD must be mounted at /mnt/ssd first.' 42 local source 43 source=$(findmnt -nro SOURCE --mountpoint /mnt/ssd) 44 [[ "$source" == /dev/mapper/ssd || "$source" == /dev/mapper/ssd\[* ]] || 45 fail 'Unexpected SSD mount source; refusing to create/open a container.' 46 } 47 verify_mapper() { 48 local loop backing 49 loop=$(cryptsetup status "$mapper" | awk '$1 == "device:" {print $2}') 50 [[ "$loop" == /dev/loop* ]] || fail 'Vault mapper is not backed by a loop device.' 51 backing=$(losetup --noheadings --raw --output BACK-FILE "$loop") 52 [[ "$backing" == "$image" || ( "$backing" == "$pending" && ! -e "$image" ) ]] || fail 'Vault mapper belongs to a different container; refusing.' 53 } 54 verify_mount() { 55 local source 56 source=$(findmnt -nro SOURCE --mountpoint "$target") 57 [[ "$(readlink -f "$source")" == "$(readlink -f "/dev/mapper/$mapper")" ]] || 58 fail 'Unexpected filesystem at /mnt/vault; refusing to unmount it.' 59 } 60 case "$1" in 61 init) 62 require_ssd 63 [[ ! -e "$image" && ! -L "$image" && ! -e "$pending" && ! -L "$pending" ]] || 64 fail 'Container or unfinished creation already exists; refusing to overwrite it.' 65 [[ ! -e "/dev/mapper/$mapper" ]] || fail 'Vault mapper already exists.' 66 ! mountpoint -q "$target" || fail 'Vault mountpoint is already occupied.' 67 # 100 GB decimal, plus 5 GB spare space for the outer filesystem. 68 available=$(df --output=avail -B1 /mnt/ssd | tail -n 1) 69 (( available >= 105000000000 )) || fail 'Need at least 105 GB free on the SSD.' 70 [[ ! -L /mnt/ssd/.secure-vault ]] || fail 'Container directory must not be a symlink.' 71 install -d -m 0700 -o root -g root /mnt/ssd/.secure-vault 72 umask 077 73 ( set -o noclobber; : > "$pending" ) 74 fallocate -l 100000000000 "$pending" 75 echo 'Creating a NEW 100 GB container. Choose a long, unique vault passphrase.' 76 echo 'This does not format or close the existing SSD.' 77 echo 'At the cryptsetup confirmation prompt, type uppercase YES.' 78 if ! cryptsetup luksFormat --type luks2 --pbkdf argon2id --iter-time 3000 "$pending"; then 79 # Only this newly created file may be removed, and only without a LUKS header. 80 if cryptsetup isLuks "$pending"; then 81 fail 'Formatting stopped but a LUKS header exists; unfinished container preserved.' 82 fi 83 rm -- "$pending" 84 fail 'Creation aborted; unused container file removed. You can retry init.' 85 fi 86 # Opening a regular file uses a cryptsetup-managed loop device. 87 cryptsetup open --type luks "$pending" "$mapper" 88 cleanup_init() { 89 if mountpoint -q "$target"; then 90 echo 'Initialization stopped with vault mounted; close apps and run secure-vault lock.' >&2 91 else 92 cryptsetup close "$mapper" || true 93 fi 94 } 95 trap cleanup_init EXIT 96 mkfs.ext4 -m 0 -L PrivateVault "/dev/mapper/$mapper" 97 install -d -m 0700 -o root -g root "$target" 98 mount -o nosuid,nodev,noexec "/dev/mapper/$mapper" "$target" 99 install -d -m 0700 -o "$owner" -g "$(id -gn "$owner")" "$target/Private" 100 umount "$target" 101 cryptsetup close "$mapper" 102 trap - EXIT 103 mv -T "$pending" "$image" 104 echo '100 GB vault created and locked. Use secure-vault open.' 105 ;; 106 open) 107 require_ssd 108 [[ -f "$image" && ! -L "$image" ]] || fail 'No vault container. Run secure-vault init once.' 109 [[ ! -e "/dev/mapper/$mapper" ]] || fail 'Vault mapper is already open; check status.' 110 ! mountpoint -q "$target" || fail 'Vault mountpoint is already occupied.' 111 cryptsetup open --type luks "$image" "$mapper" 112 if ! mount "$target"; then 113 cryptsetup close "$mapper" 114 fail 'Mount failed; vault mapping closed.' 115 fi 116 echo 'Vault open: /mnt/vault/Private' 117 ;; 118 lock) 119 if cryptsetup status "$mapper" >/dev/null 2>&1; then 120 verify_mapper 121 if mountpoint -q "$target"; then 122 verify_mount 123 umount "$target" 124 fi 125 cryptsetup close "$mapper" 126 else 127 ! mountpoint -q "$target" || fail 'Vault mountpoint occupied but mapper absent; inspect manually.' 128 fi 129 echo 'Private vault locked. Existing SSD remains available.' 130 ;; 131 backup-header) 132 require_ssd 133 [[ -f "$image" && ! -L "$image" ]] || fail 'Vault container does not exist.' 134 destination="''${2:-}" 135 [[ "$destination" == /*.sops.json ]] || fail 'Supply an absolute destination ending in .sops.json on your backup drive.' 136 [[ ! -e "$destination" && ! -L "$destination" ]] || fail 'Backup destination already exists; refusing to overwrite it.' 137 # Only public SOPS recipients/rules enter the Nix store. 138 # Header plaintext stays in root-only /run and is removed on exit. 139 umask 077 140 header="" 141 encrypted="" 142 cleanup_header() { 143 [[ -z "$header" ]] || rm -f "$header" 144 [[ -z "$encrypted" ]] || rm -f "$encrypted" 145 return 0 146 } 147 trap cleanup_header EXIT 148 header=$(mktemp /run/secure-vault-header.XXXXXX) 149 encrypted=$(mktemp "$(dirname "$destination")/.vault-header-encrypted.XXXXXX") 150 rm "$header" 151 cryptsetup luksHeaderBackup "$image" --header-backup-file "$header" 152 sops encrypt --config ${../../../.sops.yaml} \ 153 --filename-override secrets/vault-header.sops.json \ 154 --input-type binary --output-type json "$header" > "$encrypted" 155 # Atomic publication refuses overwrite, including symlinks. 156 ln "$encrypted" "$destination" 157 chown "$owner:$(id -gn "$owner")" "$destination" 158 echo "SOPS-encrypted header backup: $destination" 159 ;; 160 status) 161 cryptsetup status "$mapper" || true 162 findmnt --mountpoint "$target" || true 163 ;; 164 busy) 165 mountpoint -q "$target" || fail 'Vault is not mounted.' 166 fuser -vm "$target" 167 ;; 168 esac 169 ''; 170 }; 171 in { 172 environment.systemPackages = with pkgs; [ cryptsetup btrfs-progs e2fsprogs keepassxc age restic vault ]; 173 174 environment.etc.crypttab.text = '' 175 ssd UUID=8cd17d0f-adf3-430f-9d95-d17ef42df9b0 /etc/secrets/ssd.key luks,nofail 176 ''; 177 178 # The new inner container is independent of the existing SSD unlock. 179 fileSystems."/mnt/vault" = { 180 device = "/dev/mapper/netrunner-private"; 181 fsType = "ext4"; 182 options = [ "noauto" "nofail" "nosuid" "nodev" "noexec" ]; 183 }; 184 systemd.tmpfiles.rules = [ "d /mnt/vault 0700 root root -" ]; 185 186 fileSystems."/mnt/ssd" = { 187 device = "/dev/mapper/ssd"; 188 fsType = "btrfs"; 189 options = [ 190 "compress=zstd:3" 191 "noatime" 192 "nofail" 193 "x-systemd.device-timeout=10s" 194 ]; 195 }; 196 } 197 ; 198 }