From a75924aca4ad20275557332956fd2d8665160eab Mon Sep 17 00:00:00 2001 From: Akshar Patel Date: Sat, 5 Sep 2026 17:57:11 -0400 Subject: [PATCH 1/2] Erase old password hashes during factory reset --- bin/omarchy-system-factory-reset | 39 ++++-- test/shell.d/factory-reset-accounts-test.sh | 146 ++++++++++++++++++++ 2 files changed, 171 insertions(+), 14 deletions(-) create mode 100644 test/shell.d/factory-reset-accounts-test.sh diff --git a/bin/omarchy-system-factory-reset b/bin/omarchy-system-factory-reset index 51eca401..f9e57d95 100755 --- a/bin/omarchy-system-factory-reset +++ b/bin/omarchy-system-factory-reset @@ -297,19 +297,34 @@ rebuild_next_boot() { umount "$next$esp_mount" } +# Both the staged system and the retained baseline must lose the old hashes. +scrub_factory_accounts() { + local root="$1" user users + + users=$(awk -F: '$3 >= 1000 && $3 < 60000 { print $1 }' "$root/etc/passwd") || return 1 + for user in $users; do + userdel --root "$root" "$user" 2>>"$LOG_FILE" || return 1 + rm -rf "${root:?}/home/$user" || return 1 + done + + # passwd --lock preserves the hash. Replace it, then remove the backups + # that userdel and usermod leave behind. + usermod --root "$root" --password '!' root >>"$LOG_FILE" 2>&1 || return 1 + rm -f "$root/etc/"{shadow-,gshadow-,passwd-,group-} +} + # Remove the seller's account material and machine identity from the retained # @factory baseline so it can neither be mounted for recovery nor restore the # seller's account on a future reset. Idempotent (a scrubbed baseline has no # uid>=1000 accounts left to remove). sanitize_factory_baseline() { - local factory="$1" user + local factory="$1" btrfs property set -ts "$factory" ro false - for user in $(awk -F: '$3 >= 1000 && $3 < 60000 { print $1 }' "$factory/etc/passwd"); do - userdel --root "$factory" "$user" 2>>"$LOG_FILE" || true - rm -rf "${factory:?}/home/$user" - done - passwd --root "$factory" --lock root >>"$LOG_FILE" 2>&1 || true + if ! scrub_factory_accounts "$factory"; then + btrfs property set -ts "$factory" ro true + fail "could not remove account credentials from the factory baseline (see $LOG_FILE)" + fi rm -f "$factory"/etc/ssh/ssh_host_* rm -f "$factory"/etc/NetworkManager/system-connections/* rm -rf "$factory"/var/lib/NetworkManager/* "$factory/var/lib/tailscale" "$factory/var/lib/iwd" @@ -337,17 +352,13 @@ stage_full_reset() { rm -rf "$next"/var/lib/NetworkManager/* "$next/var/lib/tailscale" "$next/var/lib/iwd" rm -f "$next/var/lib/sddm/state.conf" "$next/etc/sddm.conf.d/autologin.conf" - # A factory snapshot from a normal (normal) install contains the original + # A factory snapshot from a normal install contains the original # user account; first-boot setup must start from none. A leftover account # would keep its password hash and group memberships (including wheel), so # failure here has to abort the reset, not be shrugged off. - local user - for user in $(awk -F: '$3 >= 1000 && $3 < 60000 { print $1 }' "$next/etc/passwd"); do - log "Removing user $user from the factory system" - userdel --root "$next" "$user" 2>>"$LOG_FILE" || - fail "could not remove user $user from the factory system (see $LOG_FILE)" - done - passwd --root "$next" --lock root >>"$LOG_FILE" 2>&1 || true + log "Removing account credentials from the factory system" + scrub_factory_accounts "$next" || + fail "could not remove account credentials from the factory system (see $LOG_FILE)" # @factory itself survives the wipe as the baseline for future resets. If it # came from a normal install it still holds the seller's account and diff --git a/test/shell.d/factory-reset-accounts-test.sh b/test/shell.d/factory-reset-accounts-test.sh new file mode 100644 index 00000000..7f6f4a31 --- /dev/null +++ b/test/shell.d/factory-reset-accounts-test.sh @@ -0,0 +1,146 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +if (( EUID != 0 )); then + if unshare --user --map-root-user true 2>/dev/null; then + exec unshare --user --map-root-user bash "$0" + fi + pass "no unprivileged user namespace; skipping factory account cleanup" + exit 0 +fi + +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT + +# Load the production functions without self-elevation or the reset entrypoint. +awk ' + /^[a-z_]+\(\) \{/ { copying = 1 } + copying { print } + /^}/ { copying = 0 } +' "$ROOT/bin/omarchy-system-factory-reset" >"$test_tmp/functions" + +cat >"$test_tmp/reset" <<'SH' +#!/bin/bash +set -euo pipefail +source "$1/functions" +TOP_MNT="$2" +NEXT_NAME=@omarchy-reset-next +PROVISIONING_DIR=/var/lib/omarchy/provisioning +LOG_FILE="$TOP_MNT/reset.log" + +log() { printf '%s\n' "$1" >>"$LOG_FILE"; } +fail() { log "$1"; exit 1; } + +# Account tools are real. Only snapshots, boot rebuilding, and system services +# are replaced: all writes stay inside this test's disposable directory. +btrfs() { + if [[ $1 == "subvolume" && $2 == "snapshot" ]]; then + mkdir -p "$4" + cp -a "$3/." "$4/" + elif [[ $1 == "property" ]]; then + printf '%s\n' "$6" >"$4/read-only" + else + return 1 + fi +} +systemd-id128() { printf '%032d\n' 1; } +install_provisioning_units() { :; } +encrypted_install() { return 1; } +rebuild_next_boot() { touch "$TOP_MNT/rebuilt"; } +sync() { :; } + +userdel() { + [[ ${FAIL_COMMAND:-} == "userdel" && $2 == "$FAIL_ROOT" ]] && return 42 + command userdel "$@" +} +usermod() { + [[ ${FAIL_COMMAND:-} == "usermod" && $2 == "$FAIL_ROOT" ]] && return 42 + command usermod "$@" +} +rm() { + [[ ${FAIL_COMMAND:-} == "rm" && $* == *"$FAIL_ROOT/etc/shadow-"* ]] && return 42 + command rm "$@" +} + +stage_full_reset +SH + +make_fixture() { + local top="$1" root_hash="${2:-original-root-hash}" + local factory="$top/@factory" + mkdir -p "$top/@" "$factory/etc" "$factory/home/seller" \ + "$factory/usr/bin" "$factory/usr/share/omarchy/install/provisioning" \ + "$factory/var/lib/omarchy/provisioning/packages" + touch "$top/@/old-system" "$factory/home/seller/private-file" \ + "$factory/usr/share/omarchy/install/provisioning/omarchy-provision-owner.service" \ + "$factory/var/lib/omarchy/provisioning/packages/node-v0.tar.gz" + printf '#!/bin/bash\n' >"$factory/usr/bin/omarchy-provision-owner" + chmod +x "$factory/usr/bin/omarchy-provision-owner" + printf 'true\n' >"$factory/read-only" + cat >"$factory/etc/passwd" <<'EOF' +root:x:0:0:root:/root:/bin/bash +daemon:x:1:1:daemon:/:/usr/bin/nologin +seller:x:1000:1000:Seller:/home/seller:/bin/bash +EOF + printf 'root:%s:20000:0:99999:7:::\ndaemon:*:20000:0:99999:7:::\nseller:original-user-hash:20000:0:99999:7:::\n' \ + "$root_hash" >"$factory/etc/shadow" + printf 'root:x:0:\ndaemon:x:1:\nseller:x:1000:\nwheel:x:998:seller\n' >"$factory/etc/group" + printf 'root:!::\ndaemon:!::\nseller:!::\nwheel:!::seller\n' >"$factory/etc/gshadow" + printf 'USERGROUPS_ENAB yes\n' >"$factory/etc/login.defs" + chmod 600 "$factory/etc/"{shadow,gshadow} + for file in passwd shadow group gshadow; do + cp "$factory/etc/$file" "$factory/etc/$file-" + done +} + +assert_scrubbed() { + local root="$1" file + [[ $(awk -F: '$1 == "root" { print $2 }' "$root/etc/shadow") == "!" ]] || + fail "reset erases the root hash while keeping the account locked" + ! grep -q 'original-.*-hash\|seller' "$root/etc/"{passwd,shadow,group,gshadow} || + fail "reset removes seller account credentials and group membership" + [[ ! -e $root/home/seller ]] || fail "reset removes the seller's baseline home" + grep -q '^daemon:\*:' "$root/etc/shadow" || fail "reset preserves service accounts" + [[ $(stat -c '%a' "$root/etc/shadow") == "600" ]] || fail "shadow stays private" + for file in passwd shadow group gshadow; do + [[ ! -e $root/etc/$file- ]] || fail "reset removes the $file backup" + done +} + +for scenario in normal locked; do + top="$test_tmp/$scenario" + if [[ $scenario == "locked" ]]; then + make_fixture "$top" '!' + else + make_fixture "$top" + fi + bash "$test_tmp/reset" "$test_tmp" "$top" || fail "$scenario reset stages successfully" + assert_scrubbed "$top/@factory" + assert_scrubbed "$top/@" + [[ $(cat "$top/@factory/read-only") == "true" ]] || fail "baseline returns to read-only" + [[ -f $top/@/var/lib/omarchy/provisioning/pending && -f $top/rebuilt ]] || + fail "reset reaches provisioning after cleanup" + + bash "$test_tmp/reset" "$test_tmp" "$top" || fail "$scenario reset can be repeated" + assert_scrubbed "$top/@factory" + assert_scrubbed "$top/@" + pass "$scenario reset scrubs both roots, preserves service accounts, and can be repeated" +done + +for target in @omarchy-reset-next @factory; do + for command in userdel usermod rm; do + top="$test_tmp/fail-$target-$command" + make_fixture "$top" + if FAIL_COMMAND="$command" FAIL_ROOT="$top/$target" bash "$test_tmp/reset" "$test_tmp" "$top"; then + fail "reset accepted failed $command in $target" + fi + [[ -f $top/@/old-system && ! -e $top/rebuilt ]] || + fail "failed cleanup must not activate or rebuild the reset system" + [[ $(cat "$top/@factory/read-only") == "true" ]] || + fail "failed cleanup must leave the baseline read-only" + pass "failed $command in $target aborts reset before activation" + done +done From a00be8fa16c941749886a80d91413e9bbe3c09e4 Mon Sep 17 00:00:00 2001 From: omarchybot Date: Sun, 6 Sep 2026 05:37:10 -0700 Subject: [PATCH 2/2] Remove the subuid and subgid backups during the factory scrub userdel rewrites /etc/subuid and /etc/subgid, and like every shadow-utils database write it leaves the previous contents behind in a dash-suffixed backup. The scrub removed four of the six backups those tools produce, so the retained @factory baseline still named the previous owner in /etc/subuid- and /etc/subgid- along with their subordinate ID range. Co-Authored-By: Claude Opus 5 (1M context) --- bin/omarchy-system-factory-reset | 2 +- test/shell.d/factory-reset-accounts-test.sh | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/bin/omarchy-system-factory-reset b/bin/omarchy-system-factory-reset index f9e57d95..43348e5a 100755 --- a/bin/omarchy-system-factory-reset +++ b/bin/omarchy-system-factory-reset @@ -310,7 +310,7 @@ scrub_factory_accounts() { # passwd --lock preserves the hash. Replace it, then remove the backups # that userdel and usermod leave behind. usermod --root "$root" --password '!' root >>"$LOG_FILE" 2>&1 || return 1 - rm -f "$root/etc/"{shadow-,gshadow-,passwd-,group-} + rm -f "$root/etc/"{shadow-,gshadow-,passwd-,group-,subuid-,subgid-} } # Remove the seller's account material and machine identity from the retained diff --git a/test/shell.d/factory-reset-accounts-test.sh b/test/shell.d/factory-reset-accounts-test.sh index 7f6f4a31..ed2bb697 100644 --- a/test/shell.d/factory-reset-accounts-test.sh +++ b/test/shell.d/factory-reset-accounts-test.sh @@ -90,8 +90,10 @@ EOF printf 'root:x:0:\ndaemon:x:1:\nseller:x:1000:\nwheel:x:998:seller\n' >"$factory/etc/group" printf 'root:!::\ndaemon:!::\nseller:!::\nwheel:!::seller\n' >"$factory/etc/gshadow" printf 'USERGROUPS_ENAB yes\n' >"$factory/etc/login.defs" + printf 'seller:100000:65536\n' >"$factory/etc/subuid" + printf 'seller:100000:65536\n' >"$factory/etc/subgid" chmod 600 "$factory/etc/"{shadow,gshadow} - for file in passwd shadow group gshadow; do + for file in passwd shadow group gshadow subuid subgid; do cp "$factory/etc/$file" "$factory/etc/$file-" done } @@ -105,7 +107,7 @@ assert_scrubbed() { [[ ! -e $root/home/seller ]] || fail "reset removes the seller's baseline home" grep -q '^daemon:\*:' "$root/etc/shadow" || fail "reset preserves service accounts" [[ $(stat -c '%a' "$root/etc/shadow") == "600" ]] || fail "shadow stays private" - for file in passwd shadow group gshadow; do + for file in passwd shadow group gshadow subuid subgid; do [[ ! -e $root/etc/$file- ]] || fail "reset removes the $file backup" done }