diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a030c8..cf9937c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- `chage -l` did not work for a user on their own account, the one mode the + tool allows them: `make install` put `chage` at `0755`, which cannot read + `/etc/shadow`, and the multicall binary dropped its privilege before running + it. Debian ships `chage` setgid `shadow`, Fedora and Arch setuid root; it + now follows `expiry` -- setgid `shadow`, or setuid root where the group is + absent, and an applet that keeps euid 0 in the multicall binary. What the + caller may do is still decided from the real uid, so a user gets their own + line and nothing else; the deployment suite now checks both halves of that + - `make install` failed on any system without a `shadow` group -- Fedora, Arch, or a packager's chroot -- because `expiry` was installed setgid to a group that was not there. On such systems `/etc/shadow` is readable by root diff --git a/Makefile b/Makefile index c62c695..0f3e956 100644 --- a/Makefile +++ b/Makefile @@ -15,14 +15,16 @@ ROOT_TOOLS = useradd userdel usermod chpasswd chgpasswd newusers \ # sbin is not on a normal user's PATH, so `passwd` there is `command not # found`. This is where GNU shadow puts them -- verified against the Debian # package, which ships passwd, chage, chfn, chsh and newgrp in /usr/bin and -# everything else in /usr/sbin. chage is here for `chage -l`, the one mode a -# user may run on their own account. -# login lives in bin too, and is root-only without being setuid: getty runs -# it as root, and it refuses to run as anyone else. -USER_BIN_TOOLS = chage login -# expiry reads the caller's own /etc/shadow line and nothing else, and the GNU -# suite ships it setgid shadow -- enough to read the file, no more. -SETGID_SHADOW_TOOLS = expiry +# everything else in /usr/sbin. +# login lives in bin, and is root-only without being setuid: getty runs it as +# root, and it refuses to run as anyone else. +USER_BIN_TOOLS = login +# Tools a user runs on their own account that have to read /etc/shadow for +# it: expiry reads the caller's line to judge it, chage -l reads it to print +# it. Neither writes anything for a caller who is not root -- both check the +# real uid -- so the GNU suite ships them setgid shadow, enough to read the +# file and no more. +SETGID_SHADOW_TOOLS = expiry chage # The group allowed to read /etc/shadow. Debian and Ubuntu have one, `shadow`, # and that is what the tools above are made setgid to. Fedora and Arch have no diff --git a/README.md b/README.md index bd7c503..95e1b8b 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ default-in-Ubuntu in under 3 years. This project follows that playbook. | `userdel` | **Implemented.** Remove from all system files, optional home/mail cleanup. | | `usermod` | **Implemented.** Modify all properties, group membership, lock/unlock, set pre-hashed password. | | `chpasswd` | **Implemented.** Batch password change from stdin. | -| `chage` | **Implemented.** Password aging management, `-l` list mode. | +| `chage` | **Implemented.** Password aging management; `-l` for a user's own account, setgid `shadow`. | | `groupadd` | **Implemented.** Auto GID allocation, system groups, force mode. | | `groupdel` | **Implemented.** Primary group usage check. | | `groupmod` | **Implemented.** GID change, rename, password. | @@ -152,8 +152,8 @@ docker compose run --rm debian cargo build --release Default install: 28 standalone per-tool binaries with least-privilege setuid layout matching GNU shadow-utils. Only `passwd`, `chfn`, `chsh`, `newgrp`, -`gpasswd`, `sg`, `newuidmap` and `newgidmap` are installed setuid-root, `expiry` is setgid `shadow`; the other 19 are plain `0755`. -On a system with no `shadow` group -- Fedora, Arch -- `expiry` is installed setuid-root instead, the way those systems ship the GNU tools that read `/etc/shadow` for a user; a group under another name is named with `SHADOW_GROUP=`. +`gpasswd`, `sg`, `newuidmap` and `newgidmap` are installed setuid-root, `expiry` and `chage` are setgid `shadow` (enough to read a user's own `/etc/shadow` line, which `expiry` and `chage -l` need); the other 18 are plain `0755`. +On a system with no `shadow` group -- Fedora, Arch -- those two are installed setuid-root instead, the way those systems ship the GNU `chage`; a group under another name is named with `SHADOW_GROUP=`. ```shell sudo make install PREFIX=/usr/local @@ -212,8 +212,8 @@ for tool in passwd chfn chsh newgrp gpasswd sg chage chpasswd chgpasswd newusers done ``` -Mode `4755` is what the eight setuid applets need; the others give the -privilege up before running. Run `shadow-rs --list` to see the applets a +Mode `4755` is what the ten applets that keep euid 0 need; the others give +the privilege up before running. Run `shadow-rs --list` to see the applets a given build contains, and `sha256sum -c uu_shadow-*.tar.gz.sha256` to verify a download. diff --git a/docs/man/chage.1.md b/docs/man/chage.1.md index 249ba07..e148390 100644 --- a/docs/man/chage.1.md +++ b/docs/man/chage.1.md @@ -16,7 +16,10 @@ itself expires. The fields live in /etc/shadow (see **shadow**(5)) and are counted in **days since 1970-01-01**, not in dates. **chage -l** prints those fields in a readable form and is the only mode a -normal user may run, and only for their own account. Every other mode requires +normal user may run, and only for their own account. Reading /etc/shadow for +that is why the binary is installed setgid *shadow* (setuid root on systems +with no such group); what the caller may do is decided from their real uid, so +the privilege buys a user nothing beyond their own line. Every other mode requires the superuser. ## OPTIONS diff --git a/src/bin/shadow-rs.rs b/src/bin/shadow-rs.rs index dba4f1c..1314e82 100644 --- a/src/bin/shadow-rs.rs +++ b/src/bin/shadow-rs.rs @@ -26,16 +26,18 @@ use std::process::ExitCode; type Applet = fn(&[OsString]) -> i32; -/// Applets that keep euid 0 for an unprivileged caller: the same eight that -/// `make install` marks setuid. `sg` is here for the reason `newgrp` is -- -/// it has to read `/etc/gshadow` to check a group password, and the GNU suite -/// ships it as a symlink to the setuid `newgrp` for exactly that. The two id -/// mappers are here because writing a namespace's map beyond the caller's -/// own id needs `CAP_SETUID`/`CAP_SETGID` in the parent namespace. `expiry` -/// is setgid `shadow` in the per-tool install, which is only enough to read -/// `/etc/shadow`; a single setuid binary has nothing that narrow to offer, so -/// it keeps euid 0 here and reads the one line it needs. -const SETUID_APPLETS: [&str; 9] = [ +/// Applets that keep euid 0 for an unprivileged caller: the eight that +/// `make install` marks setuid, and the two it marks setgid `shadow`. `sg` is +/// here for the reason `newgrp` is -- it has to read `/etc/gshadow` to check +/// a group password, and the GNU suite ships it as a symlink to the setuid +/// `newgrp` for exactly that. The two id mappers are here because writing a +/// namespace's map beyond the caller's own id needs `CAP_SETUID`/`CAP_SETGID` +/// in the parent namespace. `expiry` and `chage -l` read the caller's own +/// `/etc/shadow` line; setgid `shadow` is enough for that in the per-tool +/// install, but a single setuid binary has nothing that narrow to offer, so +/// they keep euid 0 here. Both decide what the caller may do from the real +/// uid, so a user gets that one line and nothing else. +const SETUID_APPLETS: [&str; 10] = [ "passwd", "chfn", "chsh", @@ -45,6 +47,7 @@ const SETUID_APPLETS: [&str; 9] = [ "newuidmap", "newgidmap", "expiry", + "chage", ]; /// Every applet compiled into this binary, by name, in `--list` order. @@ -313,8 +316,9 @@ mod tests { assert!(names.iter().all(|n| ALL_TOOLS.contains(n))); } - // Exactly the tools that the per-tool install marks setuid keep the - // privilege; every other applet gives it up before running. + // Exactly the tools that the per-tool install marks setuid or setgid + // `shadow` keep the privilege; every other applet gives it up before + // running. #[test] fn only_self_service_tools_keep_privilege() { for tool in ALL_TOOLS { @@ -331,6 +335,7 @@ mod tests { | "newuidmap" | "newgidmap" | "expiry" + | "chage" ), "{tool}" ); diff --git a/tests/e2e/deploy-test.sh b/tests/e2e/deploy-test.sh index 0992bb0..16852f2 100755 --- a/tests/e2e/deploy-test.sh +++ b/tests/e2e/deploy-test.sh @@ -872,12 +872,21 @@ test_gpasswd_group_admin() { # ── usermod subordinate ids, and expiry ──────────────────────────── test_subid_and_expiry() { - section "usermod -v/-V/-w/-W, and expiry" + section "usermod -v/-V/-w/-W, expiry, and chage -l as a user" userdel -r ex_user 2>/dev/null || true assert_ok "useradd -m ex_user" useradd -m -s /bin/sh ex_user assert_ok "set a password" bash -c "printf 'ex_user:oldpw\n' | chpasswd" + # chage -l is the one mode a user may run on their own account, and it + # has to read /etc/shadow to answer: the tool keeps euid 0 for that. The + # real uid still decides what the caller may do. + assert_contains "chage -l on one's own account answers a user" "Last password change" \ + su -s /bin/sh ex_user -c 'chage -l ex_user' + assert_fail "chage -l on another account is refused" su -s /bin/sh ex_user -c 'chage -l root' + assert_fail "chage -M as a user is refused" su -s /bin/sh ex_user -c 'chage -M 5 ex_user' + assert_fail "chage --root as a user is refused" su -s /bin/sh ex_user -c 'chage --root / -l ex_user' + assert_ok "usermod -v grants a subuid range" usermod -v 500000-500999 ex_user assert_file_contains "the range is in /etc/subuid" /etc/subuid '^ex_user:500000:1000$' assert_ok "usermod -V of half of it splits the entry" usermod -V 500000-500499 ex_user