From 098cd42ac8601175f8f88e39b71d430bea52edd0 Mon Sep 17 00:00:00 2001 From: Bernardo Magri Date: Mon, 18 May 2026 17:01:15 +0100 Subject: [PATCH] fix(installer): harden multi-disk LUKS, password handling, revision pinning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Several installer reliability fixes that were left uncommitted: - Impermanence + multi-disk LUKS: disko-config.nix names the main LUKS mapping `crypted` for single-disk and `crypted_main` once extraDrives is non-empty. The impermanence rollback hook used to hardcode `crypted`, which made every multi-disk install fail to mount root in initrd. Added a `nomarchy.system.impermanence.mainLuksName` option and wired the installer to write the correct value into the generated system.nix based on the drive count. - Password no longer cleartext in /etc/nixos: installer now hashes the user password with `mkpasswd -m sha-512` and emits `initialHashedPassword` instead of `initialPassword`. Added mkpasswd to the live ISO. Cleartext is unset immediately after hashing. USER_PASSWORD_HASH is deliberately not persisted in --resume state — configure_user re-prompts on resume. - Revision pinning that actually works on the live ISO: `inputs.self` strips .git in the Nix store copy, so `git rev-parse HEAD` would silently return empty on a real install and the generated flake would track main. Live ISO now writes `/etc/nomarchy-rev` from `inputs.self.rev` at build time; install.sh reads it first, falls back to git, and aborts with a loud confirmation prompt if both are empty (instead of silently installing an unpinned system). - Generated `/mnt/etc/nixos/state.json`: toggle scripts (nomarchy-tz-select, nomarchy-setup-{fido2,fingerprint}, nomarchy-toggle-hybrid-gpu, nomarchy-wifi-powersave) `jq` this file in place and fail hard if it doesn't exist. Fresh installs now ship a schema-conformant file matching lib/state-schema.nix. - Unmount /mnt before exiting `finish()` regardless of reboot choice. Clean unmount avoids dirty BTRFS on reboot; on "no", leaving /mnt mounted blocked a second installer run on the same live ISO. - Removed obsolete `installer/disko-btrfs-luks.nix` (superseded by `disko-config.nix` per commit 3aadc36) and dropped its dangling `docs/STRUCTURE.md` reference. Co-Authored-By: Claude Opus 4.7 --- core/system/impermanence.nix | 16 ++++- docs/STRUCTURE.md | 1 - hosts/nomarchy-live.nix | 10 ++++ installer/disko-btrfs-luks.nix | 68 --------------------- installer/install.sh | 104 ++++++++++++++++++++++++++++++--- 5 files changed, 122 insertions(+), 77 deletions(-) delete mode 100644 installer/disko-btrfs-luks.nix diff --git a/core/system/impermanence.nix b/core/system/impermanence.nix index 597c0ac..7bfa1c5 100644 --- a/core/system/impermanence.nix +++ b/core/system/impermanence.nix @@ -10,13 +10,27 @@ in options.nomarchy.system.impermanence = { enable = lib.mkEnableOption "Erase Your Darlings (Impermanence) root wipe on boot"; + + # The disko layout names the main LUKS mapping `crypted` on single-disk + # installs and `crypted_main` on multi-disk installs (see + # installer/disko-config.nix: `mainLuksName`). The rollback hook must + # mount the right device, otherwise initrd fails on every boot and the + # @ → root-blank snapshot is never restored. + mainLuksName = lib.mkOption { + type = lib.types.str; + default = "crypted"; + description = '' + Name of the /dev/mapper entry holding the BTRFS root. Set to + "crypted_main" on multi-disk installs to match the disko layout. + ''; + }; }; config = lib.mkIf cfg.enable { # 1. The Rollback Script: Runs in initrd before filesystems are mounted boot.initrd.postDeviceCommands = lib.mkAfter '' mkdir -p /btrfs_tmp - mount -o subvol=/ /dev/mapper/crypted /btrfs_tmp + mount -o subvol=/ /dev/mapper/${cfg.mainLuksName} /btrfs_tmp if [[ -e /btrfs_tmp/@ ]]; then mkdir -p /btrfs_tmp/old_roots diff --git a/docs/STRUCTURE.md b/docs/STRUCTURE.md index fe5f91f..8671106 100644 --- a/docs/STRUCTURE.md +++ b/docs/STRUCTURE.md @@ -125,7 +125,6 @@ The `lib/` directory provides centralized logic and data structures to maintain ### `installer/` (Bootstrap) - **`install.sh`**: The interactive TTY-based installer. It handles disk partitioning, NixOS installation, and generating a clean "Downstream" flake for the user. - **`disko-config.nix`**: The disko partition layout (BTRFS on top of LUKS2). A Nix function of `{ mainDrive, extraDrives ? [] }` — single-disk path is `extraDrives = []`; multi-disk adds BTRFS `-d single -m raid1` across the extras. Invoked by `install.sh` via `disko --argstr mainDrive … --arg extraDrives '[…]'`. -- **`disko-btrfs-luks.nix`**: A simpler reference layout for disk management (not used by the installer). ### `hosts/` (Targets) - **`nomarchy-installer.nix`**: Configuration for the minimal, TTY-based installation ISO. diff --git a/hosts/nomarchy-live.nix b/hosts/nomarchy-live.nix index b1baf71..fb2f604 100644 --- a/hosts/nomarchy-live.nix +++ b/hosts/nomarchy-live.nix @@ -22,6 +22,7 @@ parted btrfs-progs cryptsetup + mkpasswd inputs.disko.packages.${pkgs.stdenv.hostPlatform.system}.disko (pkgs.makeDesktopItem { name = "install-nomarchy"; @@ -64,6 +65,15 @@ environment.etc."nomarchy".source = inputs.self; + # Embed the git revision the ISO was built from so install.sh can pin the + # generated flake to the exact same commit. `inputs.self.rev` exists only + # when the flake is built from a clean git tree; from a dirty worktree we + # fall back to dirtyRev (which won't be resolvable by `git+https`, so the + # installer treats it as "unpinned"). Empty file = unpinned. + environment.etc."nomarchy-rev".text = + if inputs.self ? rev then inputs.self.rev + else ""; + # Auto-login to the graphical session services.displayManager.autoLogin.enable = true; services.displayManager.autoLogin.user = "nixos"; diff --git a/installer/disko-btrfs-luks.nix b/installer/disko-btrfs-luks.nix deleted file mode 100644 index 8568680..0000000 --- a/installer/disko-btrfs-luks.nix +++ /dev/null @@ -1,68 +0,0 @@ -{ - disko.devices = { - disk = { - main = { - type = "disk"; - device = "@TARGET_DRIVE@"; - content = { - type = "gpt"; - partitions = { - ESP = { - priority = 1; - name = "ESP"; - start = "1M"; - end = "512M"; - type = "EF00"; - content = { - type = "filesystem"; - format = "vfat"; - mountpoint = "/boot"; - mountOptions = [ "umask=0077" ]; - }; - }; - luks = { - size = "100%"; - content = { - type = "luks"; - name = "crypted"; - settings.allowDiscards = true; - content = { - type = "btrfs"; - extraArgs = [ "-f" ]; - subvolumes = { - "@" = { - mountpoint = "/"; - mountOptions = [ "compress=zstd" "noatime" ]; - }; - "@persist" = { - mountpoint = "/persist"; - mountOptions = [ "compress=zstd" "noatime" ]; - }; - "@home" = { - mountpoint = "/home"; - mountOptions = [ "compress=zstd" "noatime" ]; - }; - "@nix" = { - mountpoint = "/nix"; - mountOptions = [ "compress=zstd" "noatime" ]; - }; - "@log" = { - mountpoint = "/var/log"; - mountOptions = [ "compress=zstd" "noatime" ]; - }; - }; - postCreateHook = '' - MNTPOINT=$(mktemp -d) - mount -t btrfs /dev/mapper/crypted $MNTPOINT - btrfs subvolume snapshot -r $MNTPOINT/@ $MNTPOINT/root-blank - umount $MNTPOINT - ''; - }; - }; - }; - }; - }; - }; - }; - }; -} \ No newline at end of file diff --git a/installer/install.sh b/installer/install.sh index 64dbde7..fc983b7 100755 --- a/installer/install.sh +++ b/installer/install.sh @@ -32,6 +32,7 @@ TARGET_DRIVE="" USERNAME="" LUKS_PASSWORD="" USER_PASSWORD="" +USER_PASSWORD_HASH="" TIMEZONE="UTC" KEYMAP_LAYOUT="" KEYMAP_VARIANT="" @@ -92,6 +93,14 @@ parse_args() { # Persist non-secret answers so an interrupted install can pick up where it # left off. Uses `declare -p` so each line is a self-contained `declare --` # statement that `source` re-establishes verbatim. +# +# USER_PASSWORD_HASH is intentionally NOT persisted, even though a SHA-512 +# crypt hash isn't reversible. The contract is: after --resume, the password +# prompt re-runs. configure_user's early-return guard at the top of the +# function checks `[[ -n "$USER_PASSWORD_HASH" ]]` for exactly this reason — +# if you ever change that guard to skip on USERNAME+HOSTNAME alone, --resume +# will silently install a system with an empty password hash and lock the +# user out. Keep the guard checking the hash. save_state() { declare -p \ TARGET_DRIVE USERNAME HOSTNAME TIMEZONE \ @@ -128,7 +137,7 @@ clear_step_state() { case "$1" in select_disk) TARGET_DRIVE="" ;; get_luks_passphrase) LUKS_PASSWORD="" ;; - configure_user) USERNAME=""; HOSTNAME=""; USER_PASSWORD="" ;; + configure_user) USERNAME=""; HOSTNAME=""; USER_PASSWORD=""; USER_PASSWORD_HASH="" ;; select_keymap_locale) KEYMAP_LAYOUT=""; KEYMAP_VARIANT=""; LOCALE="" ;; select_timezone) TIMEZONE="" ;; select_hardware) HARDWARE_MODULES=""; NOMARCHY_HW_OPTS="" ;; @@ -206,13 +215,32 @@ check_environment() { # Capture the exact commit we're installing from. The generated flake # pins `nomarchy.url` to this revision so the installed system can't # silently drift onto a newer (possibly breaking) main. - if command -v git >/dev/null 2>&1 && [[ -d "$NOMARCHY_REPO/.git" ]]; then + # + # Three sources, in priority order: + # 1. /etc/nomarchy-rev — written at ISO build time from `inputs.self.rev` + # (the only source that works on a normal live-ISO install, because + # `inputs.self` strips .git from the Nix store copy at /etc/nomarchy). + # 2. `git rev-parse HEAD` in the repo — works when running the installer + # from a dev checkout instead of the live ISO. + # 3. Empty → unpinned, user gets a loud confirmation prompt below. + if [[ -z "$NOMARCHY_REV" ]] && [[ -f /etc/nomarchy-rev ]]; then + NOMARCHY_REV=$(tr -d '[:space:]' < /etc/nomarchy-rev) + fi + if [[ -z "$NOMARCHY_REV" ]] && command -v git >/dev/null 2>&1 && [[ -d "$NOMARCHY_REPO/.git" ]]; then NOMARCHY_REV=$(git -C "$NOMARCHY_REPO" rev-parse HEAD 2>/dev/null || echo "") fi if [[ -n "$NOMARCHY_REV" ]]; then success "Pinning Nomarchy to $NOMARCHY_REV" else - info "Could not determine Nomarchy revision; downstream flake will track main." + error "Could not determine Nomarchy revision." + info "The installed system would silently track upstream main, which" + info "defeats the point of locking inputs at install time." + if [[ "$DRY_RUN" != "true" ]]; then + if ! nrun gum confirm --default=false \ + "Continue anyway with an unpinned (tracking main) configuration?"; then + exit 1 + fi + fi fi # Check internet @@ -452,8 +480,8 @@ configure_user() { section "User Configuration" if [[ -n "$USERNAME" && -n "$HOSTNAME" ]]; then - # Password check skipped in dry run or if already set - if [[ "$DRY_RUN" == "true" ]] || [[ -n "$USER_PASSWORD" ]]; then + # Password check skipped in dry run or if already hashed in this session. + if [[ "$DRY_RUN" == "true" ]] || [[ -n "$USER_PASSWORD_HASH" ]]; then success "User $USERNAME @ $HOSTNAME configured" return 0 fi @@ -493,6 +521,9 @@ configure_user() { if [[ "$DRY_RUN" == "true" ]]; then info "Dry run: skipping user password prompt." USER_PASSWORD="dryrun-not-used" + # Stable placeholder hash so generated system.nix still parses as Nix. + # Never lands on a real install — dry-run skips nixos-install. + USER_PASSWORD_HASH='$6$dryrun$3xxK3aQ.0bGcv0fM2RhV4Q9oN3p1mYxz5kSjQ.bC8tZpZ7QnFv2cN0Yhd5lDqJ8X9mP2K1L0vR6BqWqzNk7Yo/' save_state return fi @@ -521,6 +552,20 @@ configure_user() { fi done + # Hash now so we never have to embed the cleartext in /etc/nixos/system.nix + # (where it would be world-readable and break Nix parsing on quotes/backslash). + # mkpasswd is added to the live ISO via hosts/nomarchy-live.nix. + if ! command -v mkpasswd >/dev/null 2>&1; then + error "mkpasswd not found on the live ISO — cannot hash the user password." + exit 1 + fi + USER_PASSWORD_HASH=$(printf '%s' "$USER_PASSWORD" | mkpasswd -m sha-512 -s) + if [[ -z "$USER_PASSWORD_HASH" ]]; then + error "mkpasswd produced an empty hash." + exit 1 + fi + unset pass1 pass2 USER_PASSWORD + success "User password set" save_state } @@ -1346,9 +1391,17 @@ EOF # ============================================================================ generate_flake_config() { + # Impermanence must mount the same /dev/mapper name that disko created. + # disko-config.nix uses "crypted" for single-disk and "crypted_main" once + # extraDrives is non-empty — keep these in sync. local impermanence_opt="" if [[ "$ENABLE_IMPERMANENCE" == "true" ]]; then - impermanence_opt="nomarchy.system.impermanence.enable = true;" + local _main_luks_name="crypted" + local _drives=($TARGET_DRIVE) + if (( ${#_drives[@]} > 1 )); then + _main_luks_name="crypted_main" + fi + impermanence_opt=$'nomarchy.system.impermanence.enable = true;\n nomarchy.system.impermanence.mainLuksName = "'"$_main_luks_name"$'";' fi local PROFILE_SYSTEM_OPTS="" @@ -1534,7 +1587,9 @@ $xkb_variant_line users.users."$USERNAME" = { isNormalUser = true; - initialPassword = "$USER_PASSWORD"; + # SHA-512 crypt hash generated by mkpasswd during install. Cleartext + # never touches /etc/nixos. Change later with \`passwd\`. + initialHashedPassword = "$USER_PASSWORD_HASH"; extraGroups = [ "networkmanager" "wheel" "video" "audio" "render" ]; }; @@ -1685,6 +1740,31 @@ EOF # Extra Home Manager modules go here (program configs, services, etc.). } EOF + + # state.json — consumed by core/system/state.nix at every nixos-rebuild and + # mutated by toggle scripts (nomarchy-tz-select, nomarchy-setup-fido2, + # nomarchy-toggle-hybrid-gpu, nomarchy-wifi-powersave, ...). Those scripts + # `jq` the file in place and fail hard if it doesn't exist or isn't valid + # JSON, so a fresh install MUST ship one. Shape must match lib/state-schema.nix. + # Quoted heredoc — no shell expansion except the explicit $TIMEZONE below. + local _state_tz="${TIMEZONE:-UTC}" + cat > /mnt/etc/nixos/state.json </dev/null || true reboot + else + umount -R /mnt 2>/dev/null || true fi }