NixDaemon

NixOS pentest workstation as one flake — IceBreaker's successor
git clone https://git.daemon-sec.xyz/NixDaemon.git
Log | Files | Refs | README

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 }