diff --git a/bin/omarchy-sudo-passwordless b/bin/omarchy-sudo-passwordless index 5f7cd1dc..92d7ae16 100755 --- a/bin/omarchy-sudo-passwordless +++ b/bin/omarchy-sudo-passwordless @@ -28,6 +28,7 @@ readonly STATE_DIR=/var/lib/omarchy/sudo-passwordless readonly RUNTIME_DIR=/run/omarchy/sudo-passwordless readonly LOCK_FILE=/run/lock/omarchy-sudo-passwordless.lock readonly BOOT_CLEANUP_FILE=/etc/tmpfiles.d/omarchy-nopasswd-sudo.conf +readonly PACKAGE_HOOK=/usr/share/libalpm/hooks/05-omarchy-passwordless-revoke.hook readonly REMOVAL_BLOCKER=/run/omarchy-sudo-passwordless-package-removing readonly INSTALLED_SELF=/usr/bin/omarchy-sudo-passwordless readonly STATUS_INACTIVE=3 @@ -182,7 +183,11 @@ classify_generated_rule() { if [[ $suffix =~ ^[0-9]+$ ]]; then name=${contents%' ALL=(ALL) NOPASSWD: ALL'} - valid_account_name "$name" && [[ $contents == "$name ALL=(ALL) NOPASSWD: ALL" ]] + if valid_account_name "$name" && [[ $contents == "$name ALL=(ALL) NOPASSWD: ALL" ]]; then + return 0 + fi + name=${contents%%' ALL=(ALL) NOTAFTER='*} + valid_account_name "$name" && [[ $contents =~ ^[a-z_][a-z0-9_-]*\$?\ ALL=\(ALL\)\ NOTAFTER=[0-9]{14}Z\ NOPASSWD:\ ALL$ ]] elif valid_account_name "$suffix" && [[ $contents == "$suffix ALL=(ALL) NOPASSWD: ALL" ]]; then GENERATED_RULE_LEGACY_TIMER="omarchy-nopasswd-expire-${suffix}" else @@ -256,17 +261,16 @@ cleanup_all_locked() { return "$failed" } -verify_boot_cleanup() { - local owner mode canonical current active_rules - [[ ! -e $REMOVAL_BLOCKER && ! -L $REMOVAL_BLOCKER ]] || return 1 - [[ -f $BOOT_CLEANUP_FILE && ! -L $BOOT_CLEANUP_FILE ]] || return 1 - canonical=$(/usr/bin/realpath -e -- "$BOOT_CLEANUP_FILE") || return 1 - [[ $canonical == "$BOOT_CLEANUP_FILE" ]] || return 1 - owner=$(/usr/bin/stat -Lc '%u' -- "$BOOT_CLEANUP_FILE") || return 1 - mode=$(/usr/bin/stat -Lc '%a' -- "$BOOT_CLEANUP_FILE") || return 1 +verify_root_policy_file() { + local file=$1 owner mode canonical current + [[ -f $file && ! -L $file ]] || return 1 + canonical=$(/usr/bin/realpath -e -- "$file") || return 1 + [[ $canonical == "$file" ]] || return 1 + owner=$(/usr/bin/stat -Lc '%u' -- "$file") || return 1 + mode=$(/usr/bin/stat -Lc '%a' -- "$file") || return 1 [[ $owner == 0 && $mode =~ ^[0-7]+$ ]] && ! ((8#$mode & 022)) || return 1 - current=${BOOT_CLEANUP_FILE%/*} + current=${file%/*} while :; do [[ -d $current && ! -L $current ]] || return 1 canonical=$(/usr/bin/realpath -e -- "$current") || return 1 @@ -277,9 +281,36 @@ verify_boot_cleanup() { current=${current%/*} [[ -n $current ]] || current=/ done +} +verify_boot_cleanup() { + local active_rules hook + [[ ! -e $REMOVAL_BLOCKER && ! -L $REMOVAL_BLOCKER ]] || return 1 + verify_root_policy_file "$BOOT_CLEANUP_FILE" || return 1 active_rules=$(/usr/bin/awk '!/^[[:space:]]*(#|$)/ { print }' "$BOOT_CLEANUP_FILE") || return 1 - [[ $active_rules == 'r! /etc/sudoers.d/99-omarchy-nopasswd-*' ]] + [[ $active_rules == 'r! /etc/sudoers.d/99-omarchy-nopasswd-*' ]] || return 1 + verify_root_policy_file "$PACKAGE_HOOK" || return 1 + hook=$(/usr/bin/cat -- "$PACKAGE_HOOK") || return 1 + [[ $hook == '[Trigger] +Operation = Upgrade +Operation = Remove +Type = Package +Target = omarchy-settings +Target = omarchy-settings-dev + +[Action] +Description = Revoking temporary Omarchy sudo grants before settings changes... +When = PreTransaction +Exec = /usr/bin/omarchy-sudo-passwordless __package-removing +AbortOnFail' ]] +} + +package_removing_locked() { + # ALPM must abort before removing the helper or boot cleanup if revocation + # fails. The marker also blocks publication after this lock is released. + (umask 077; : >"$REMOVAL_BLOCKER") || return 1 + /usr/bin/rm -f -- /etc/sudoers.d/99-omarchy-nopasswd-* || return 1 + cleanup_all_locked } prepare_state_file() { @@ -306,10 +337,13 @@ start_expiry_timer() { } publish_rule() { - local uid="$1" name="$2" destination tmp + local uid="$1" name="$2" expires="$3" destination tmp deadline + valid_expiry "$expires" || return 1 + deadline=$(/usr/bin/date -u -d "@$expires" +%Y%m%d%H%M%SZ) || return 1 + [[ $deadline =~ ^[0-9]{14}Z$ ]] || return 1 destination=$(rule_file "$uid") tmp=$(/usr/bin/mktemp "$STATE_DIR/.sudoers.XXXXXX") || return 1 - if ! /usr/bin/printf '%s ALL=(ALL) NOPASSWD: ALL\n' "$name" >"$tmp" || + if ! /usr/bin/printf '%s ALL=(ALL) NOTAFTER=%s NOPASSWD: ALL\n' "$name" "$deadline" >"$tmp" || ! /usr/bin/chown root:root "$tmp" || ! /usr/bin/chmod 0440 "$tmp" || ! /usr/sbin/visudo -cf "$tmp" >/dev/null || ! /usr/bin/install -o root -g root -m 0440 -- "$tmp" "$destination"; then @@ -339,7 +373,7 @@ enable_locked() { valid_minutes "$minutes" || return 1 prepare_root_state || return 1 verify_boot_cleanup || { - echo "omarchy-sudo-passwordless: package-owned boot cleanup rule is missing or unsafe" >&2 + echo "omarchy-sudo-passwordless: package-owned boot cleanup or transaction hook is missing or unsafe" >&2 return 1 } @@ -363,7 +397,7 @@ enable_locked() { abort_enable_locked "$uid" "$timer" "$old_timer" "$pending_state" return 1 fi - if ! verify_boot_cleanup || ! publish_rule "$uid" "$ACCOUNT_NAME"; then + if ! verify_boot_cleanup || ! publish_rule "$uid" "$ACCOUNT_NAME" "$expires"; then abort_enable_locked "$uid" "$timer" "$old_timer" "$pending_state" return 1 fi @@ -464,12 +498,16 @@ root_dispatch() { (($# == 0)) && ((EUID == 0)) || return 1 with_root_lock cleanup_all_locked ;; + __package-removing) + (($# == 0)) && ((EUID == 0)) || return 1 + with_root_lock package_removing_locked + ;; *) return 1 ;; esac } case "${1:-}" in - __status|__enable|__disable|__expire|__cleanup-all) + __status|__enable|__disable|__expire|__cleanup-all|__package-removing) action=$1 shift root_dispatch "$action" "$@" diff --git a/default/libalpm/hooks/05-omarchy-passwordless-revoke.hook b/default/libalpm/hooks/05-omarchy-passwordless-revoke.hook new file mode 100644 index 00000000..f0bce15d --- /dev/null +++ b/default/libalpm/hooks/05-omarchy-passwordless-revoke.hook @@ -0,0 +1,12 @@ +[Trigger] +Operation = Upgrade +Operation = Remove +Type = Package +Target = omarchy-settings +Target = omarchy-settings-dev + +[Action] +Description = Revoking temporary Omarchy sudo grants before settings changes... +When = PreTransaction +Exec = /usr/bin/omarchy-sudo-passwordless __package-removing +AbortOnFail diff --git a/docs/passwordless-sudo.md b/docs/passwordless-sudo.md index b97160e4..0e805e6a 100644 --- a/docs/passwordless-sudo.md +++ b/docs/passwordless-sudo.md @@ -4,7 +4,7 @@ ## Grant lifecycle -Root state records the resolved account name, absolute expiry epoch and unique timer name. A calendar timer is armed and verified before the generated policy becomes active. Publication rechecks the package-owned boot cleanup before and after installing policy. Policy revocation must succeed before expiry jobs are stopped; a deletion error leaves those jobs armed and reports that administrator cleanup is required. +Root state records the resolved account name, absolute expiry epoch and unique timer name. A calendar timer is armed and verified before the generated policy becomes active. The sudoers rule also embeds the same UTC deadline with `NOTAFTER`, so sudo independently rejects it after expiry even if timer cleanup is delayed. Publication rechecks the package-owned boot cleanup before and after installing policy. Policy revocation must succeed before expiry jobs are stopped; a deletion error leaves those jobs armed and reports that administrator cleanup is required. An internal status result is `0` for an active, validated grant and `3` for confirmed inactive access. All other results are errors, including failed authentication and failed revocation. The user interface only offers a new grant after result `3`. It must not turn an inspection failure into a claim that no grant exists. @@ -12,9 +12,9 @@ Each new expiry callback carries its timer identity. A delayed predecessor canno ## Package ownership -The packaging companion must put the publication/expiry command, `omarchy-security-functions` and `omarchy-nopasswd-sudo.conf` in the settings package together. Removing the desktop runtime alone must leave a working expiry command behind. Stable and development package pairs must transfer ownership in one transaction without duplicate files. +The packaging companion must put the publication/expiry command, `omarchy-security-functions` `omarchy-nopasswd-sudo.conf` and the pre-transaction revocation hook in the settings package together. Removing the desktop runtime alone must leave a working expiry command behind. Stable and development package pairs must transfer ownership in one transaction without duplicate files. -Before settings removal or upgrade, its scriptlet acquires the same grant lock, sets `/run/omarchy-sudo-passwordless-package-removing` and revokes existing policy. The marker prevents a waiting publisher from creating a new grant while package files change. A successful installation clears the marker only after boot cleanup exists. Failed scriptlet cleanup returns an error and prints recovery guidance; a package-manager scriptlet failure must not be represented as an automatic transaction rollback. +Before settings removal or upgrade, the installed ALPM `PreTransaction` hook invokes the fixed `__package-removing` action, acquires the same grant lock, sets `/run/omarchy-sudo-passwordless-package-removing` and revokes existing policy. The marker prevents a waiting publisher from creating a new grant while package files change. A successful installation clears the marker only after boot cleanup exists. The hook uses `AbortOnFail` because a scriptlet failure alone does not abort pacman. The scriptlets repeat cleanup as a fallback for upgrades from older packages that have no installed hook. New grants require both the boot rule and hook before publication. Failed or interrupted transactions leave the marker set; retry the package transaction successfully before requesting another grant. The runtime marker need not survive reboot: pre-removal revokes the old grants before package files disappear, and a new invocation independently verifies boot cleanup. Both root operations use fixed machine paths. The marker is not a user-controlled mode switch. diff --git a/test/shell.d/passwordless-grant-lifecycle-test.sh b/test/shell.d/passwordless-grant-lifecycle-test.sh index c70d69e5..b3198405 100644 --- a/test/shell.d/passwordless-grant-lifecycle-test.sh +++ b/test/shell.d/passwordless-grant-lifecycle-test.sh @@ -19,7 +19,7 @@ trap cleanup EXIT # All policy, state, locks and command mutations stay in this private fixture. # Native visudo validates inert fragments; no test installs host sudo policy. -mkdir -p "$test_tmp/bin" "$test_tmp/state" "$test_tmp/etc/sudoers.d" "$test_tmp/etc/tmpfiles.d" "$test_tmp/run/lock" +mkdir -p "$test_tmp/bin" "$test_tmp/state" "$test_tmp/etc/sudoers.d" "$test_tmp/etc/tmpfiles.d" "$test_tmp/run/lock" "$test_tmp/hooks" export TEST_GRANT_ROOT="$test_tmp" cat >"$test_tmp/bin/stat" <<'STUB' #!/bin/bash @@ -60,6 +60,7 @@ library="$test_tmp/grant-functions.sh" -e "s|/var/lib/omarchy/sudo-passwordless|$test_tmp/state|g" \ -e "s|/etc/sudoers.d|$test_tmp/etc/sudoers.d|g" \ -e "s|/etc/tmpfiles.d|$test_tmp/etc/tmpfiles.d|g" \ + -e "s|/usr/share/libalpm/hooks|$test_tmp/hooks|g" \ -e "s|/run/lock/omarchy-sudo-passwordless.lock|$test_tmp/run/lock/omarchy-sudo-passwordless.lock|g" \ -e "s|/run/omarchy-sudo-passwordless-package-removing|$test_tmp/run/omarchy-sudo-passwordless-package-removing|g" \ -e "s|/usr/bin/stat|$test_tmp/bin/stat|g" \ @@ -68,6 +69,8 @@ library="$test_tmp/grant-functions.sh" -e "s|/usr/bin/systemctl|$test_tmp/bin/systemctl|g" \ -e 's|/usr/bin/chown|/usr/bin/true|g' >"$library" +cp "$ROOT/default/libalpm/hooks/05-omarchy-passwordless-revoke.hook" "$test_tmp/hooks/" + printf 'r! /etc/sudoers.d/99-omarchy-nopasswd-*\n' >"$test_tmp/etc/tmpfiles.d/omarchy-nopasswd-sudo.conf" # The expected policy text is mapped along with its filename in this fixture. sed -i "s|/etc/sudoers.d|$test_tmp/etc/sudoers.d|" "$test_tmp/etc/tmpfiles.d/omarchy-nopasswd-sudo.conf" @@ -197,3 +200,36 @@ if TEST_FAIL_RULE_DELETE=1 bash -euo pipefail -c 'source "$1"; post_remove' bash fi grep -q 'Administrator cleanup is required' "$test_tmp/removal-failure.output" || fail "package deletion failure lacks recovery guidance" pass "package removal reports cleanup failures instead of successful revocation" + +( + source "$library" + transaction_setup + rm -f "$REMOVAL_BLOCKER" + enable_locked 1000 5 + record=$(read_state_record 1000) + expiry=${record#*$'\t'} + expiry=${expiry%%$'\t'*} + deadline=$(/usr/bin/date -u -d "@$expiry" +%Y%m%d%H%M%SZ) + [[ $(cat "$(rule_file 1000)") == "audituser ALL=(ALL) NOTAFTER=$deadline NOPASSWD: ALL" ]] + /usr/sbin/visudo -cf "$(rule_file 1000)" >/dev/null + classify_generated_rule "$(rule_file 1000)" + rm -f "$(state_file 1000)" + remove_known_legacy_rules + [[ ! -e $(rule_file 1000) ]] +) || fail "native sudo deadline or state-independent bounded rule cleanup is incorrect" +pass "sudo policy contains the same deadline and bounded orphan rules are recognized" + +( + source "$library" + transaction_setup + rm -f "$REMOVAL_BLOCKER" + enable_locked 1000 5 + if TEST_FAIL_RULE_DELETE=1 package_removing_locked; then exit 1; fi + [[ -f $REMOVAL_BLOCKER && -f $(rule_file 1000) ]] + ! enable_locked 1000 5 + package_removing_locked + [[ ! -e $(rule_file 1000) ]] + rm -f "$REMOVAL_BLOCKER" "$PACKAGE_HOOK" + ! enable_locked 1000 5 +) || fail "pre-transaction revocation error or missing hook does not prevent new grants" +pass "package hook fails closed and grants require its installed policy"