Files
shadow/README.md
T
Pierre Warnier 7e28940874 expiry, and usermod's subordinate-id options (#299)
* usermod: add and remove subordinate id ranges

GNU usermod has -v/--add-subuids, -V/--del-subuids, -w/--add-subgids and
-W/--del-subgids; ours had none of them, which came to light while writing
the tests for newuidmap. The range helpers live in shadow_core::subid so
whatever next touches /etc/subuid reads the same file the same way.

The behaviour was established by running the GNU tool: a range the user
already holds is not added twice; a removal trims an entry it overlaps,
splits one it cuts through the middle of, deletes one it covers, and ignores
a range never held; nothing checks other users' entries, since an
administrator may share a range on purpose; and an invalid range --
reversed, not two numbers, above the 32-bit ids the kernel maps -- is exit 3
with the GNU wording, before any file is written. Entries are keyed by the
login name given on the command line and are not renamed by -l, as GNU's
are not: a range is a grant to a name.

-V is GNU's letter for the deletion, so clap's own -V for --version had to
go; --version stays.

* expiry: check and enforce password expiration

expiry is the twenty-eighth tool and the last the Scope table lists as in
scope. It judges the caller's own /etc/shadow line as login would: silent
when all is well, a warning inside warn_days, a forced change through PAM
when the password has expired, a refusal when the account has. Shell
profiles run it so a session that did not come through login -- a display
manager, an ssh key -- still meets the aging policy.

The rules are in shadow_core::shadow::Aging, one function over the aging
fields with checked arithmetic, since every input is read from a file anyone
who can write /etc/shadow chooses. Its order is login's: account expiry
first, then a last change of 0 or an aged-out password, then the warning
window, with a maximum age of 10000 or more meaning never as chage -l prints
it. A locked password is a policy of its own and is not reported.

The GNU tool is setgid shadow -- enough to read the file, no more -- and the
change goes through PAM as the caller. The per-tool install ships it the
same way, a new install class. The single setuid binary keeps euid 0 for it,
having nothing narrower to offer. Under --prefix it reports and never
changes anything, since a PAM change would act on this system's account of
the same name; that is also what lets the required-change path be asserted
in the integration tests without a prompt, while the e2e suite drives the
prompt itself on a real user under a pty and checks the hash changed.

groupmems is not coming. Debian 13 and Fedora do not ship it, and on Ubuntu
24.04 every action fails against PAM's default for want of a
/etc/pam.d/groupmems: a tool one distribution ships and none can use. The
Scope table says so.

Verified: make check clean with the unprivileged run; 935 tests on alpine
and 950 on fedora with PAM; 410 e2e assertions against a real install; 59
GNU comparisons; 35 arm64 assertions under emulation.

* shadow-core: stop asking nscd to invalidate a shadow cache it never had

Every passwd, chage and chpasswd ended with `nscd -i shadow`, and on a host
running nscd that printed "nscd: 'shadow' is not a known database" on the
user's terminal each time. nscd caches passwd, group, hosts, services and
netgroup; the passwd entries it holds carry the `x` placeholder, so a
password change leaves nothing of nscd's stale. Only the databases nscd
knows are passed to it now. sss_cache still gets -U for a shadow change,
which is right: sssd does cache what a password change affects.
2026-09-07 15:47:43 +02:00

14 KiB
Raw Permalink Blame History

shadow

License CI MSRV


A memory-safe reimplementation of the Linux shadow-utils in Rust. shadow-utils (useradd, passwd, groupadd, etc.) is the suite of setuid-root tools that manages user accounts, passwords, and groups on every Linux system.

Why

shadow-utils runs as root or setuid-root on every Linux system. It parses user-supplied input, writes to /etc/passwd, /etc/shadow, /etc/group, and has had recent CVEs (CVE-2023-4641: password leak in memory, CVE-2024-56433: subuid collision enabling account takeover). Before this project there was no Rust reimplementation — not in uutils, not in Prossimo/Trifecta, not on crates.io.

sudo-rs proved the model: an independent Rust rewrite of a privilege-boundary tool can go from zero to default-in-Ubuntu in under 3 years. This project follows that playbook.

Goals

  • Drop-in replacement: same flags, same exit codes, same output format as GNU shadow-utils. Differences are treated as bugs.
  • uutils compatible: built on uucore with the standard uumain() / uu_app() API contract. Designed to merge into the uutils ecosystem.
  • Memory safe: eliminate entire classes of vulnerabilities (buffer overflows, use-after-free, uninitialized memory) that affect the C original. Passwords zeroed in memory via zeroize.
  • Well-tested: unit tests, property-based tests (proptest), integration tests, fuzz targets for all parsers. Tested on Debian, Alpine (musl), and Fedora (SELinux).
  • Hardened: Landlock filesystem sandboxing, signal blocking during critical sections, core dump suppression, environment sanitization, privilege drop during PAM.
  • Auditable: small dependency tree, cargo-deny license and advisory checks, no GPL dependencies.

Status

Tool Status
passwd All 16 flags implemented. Drop-in for GNU passwd. PAM password change, Landlock sandboxing, --root, --quiet, --stdin. Output bit-for-bit identical with GNU.
pwck All checks implemented. Drop-in for GNU pwck. Bit-for-bit identical output.
useradd Implemented. UID/GID allocation, home dir + skel, shadow entry, group creation.
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.
groupadd Implemented. Auto GID allocation, system groups, force mode.
groupdel Implemented. Primary group usage check.
groupmod Implemented. GID change, rename, password.
grpck Implemented. Group/gshadow integrity verification.
chfn Implemented. GECOS sub-field modification.
chsh Implemented. Shell change with /etc/shells validation.
newgrp Implemented. Effective group change with crypt verification.
gpasswd Implemented. Group membership, administrators, and group password.
sg Implemented. Runs one command in another group; shares newgrp's authorization.
chgpasswd Implemented. Batch group passwords, applied all-or-nothing.
newusers Implemented. Batch account creation: passwd, shadow, group and home.
vipw Implemented. Edit passwd or shadow under the suite's lock; the result is checked before it is installed.
vigr Implemented. vipw for group and gshadow.
pwconv Implemented. Moves hashes from passwd into shadow and reconciles the two; shadow is written first so a failure loses nothing.
pwunconv Implemented. Merges hashes back into passwd and removes shadow.
grpconv Implemented. pwconv for group and gshadow.
grpunconv Implemented. pwunconv for group and gshadow.
login Implemented. What getty runs: prompts, PAM authentication and session, utmp/wtmp, terminal handover, a login shell. Needs the pam feature.
newuidmap Implemented. Writes a user namespace's uid_map within the caller's /etc/subuid grant; what rootless containers call.
newgidmap Implemented. newuidmap for gid_map and /etc/subgid.
expiry Implemented. Checks the caller's own password aging and forces the change login would; setgid shadow.

Scope

The target is the set of binaries the upstream shadow suite ships, as the distributions package it. That set was established from package metadata and man pages, never from the upstream source: this project is a clean-room reimplementation and reads no GPL code.

Debian and Fedora do not ship the same set. The 22 tools they agree on are all implemented. Of the rest:

tool status
login shipped. Debian 13 and Ubuntu 25.10 take login from util-linux, but every release in service today — Ubuntu 20.04, 22.04 and 24.04, Debian 12 — ships shadow's, and uutils/login was archived pointing here. It implements the option set the two share, plus util-linux's -H
expiry shipped
groupmems out of scope. Debian 13 and Fedora do not ship it; Ubuntu 24.04 does, without a /etc/pam.d/groupmems, so every action there fails against PAM's default — a tool one distribution ships and none can use
faillog, lastlog left the Debian package with 13; not planned
newuidmap, newgidmap shipped. Fedora's pair, the basis of rootless containers
shadowconfig, cpgr, cppw, adduser Debian packaging scripts and a Fedora symlink, not upstream tools; out of scope

Three tools that the GNU suite ships are provided by other projects in this organisation, and are deliberately not duplicated here — the same rule uutils/procps and uutils/util-linux apply between themselves:

  • nologin is in uutils/util-linux. Debian has taken it from util-linux for years; shadow's copy is not shipped.
  • su is in sudo-rs.
  • login is here: on the installed base it is shadow's tool, whatever the newest releases do.

Where this differs from GNU shadow

Differences with the GNU tools are treated as bugs, with one class of exceptions: a behaviour that quietly weakens the system is refused rather than reproduced. Each refusal is loud, names the alternative, and is documented in the tool's man page under Differences from GNU shadow. The pattern so far:

  • Insecure hashing schemes (-m, -c MD5, -c DES) and unhashed storage (-c NONE) are refused by chpasswd, chgpasswd and newusers.
  • An empty password in plaintext mode is refused: hashing "" yields a hash a bare Enter matches, which is an account anyone can enter, not one with no password.
  • vipw and vigr parse the edited file before installing it — GNU installs whatever the editor saved — and keep the edit when they refuse it.
  • newusers refuses a group name that does not exist rather than leaving the account pointing at a GID that is not there.
  • newgrp and sg prompt for a password whether or not the group has one, so the presence of a prompt no longer reveals which groups are worth attacking.

Nothing falls back silently. Where the GNU tool and this one disagree on a result, the exit code says so.

Building

Requirements

  • Rust (stable toolchain)
  • Linux (PAM headers, SELinux headers optional)
  • Docker + Docker Compose (for testing)

Build

git clone https://github.com/uutils/shadow
cd shadow
docker compose build debian
docker compose run --rm debian cargo build --release

Install

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.

sudo make install PREFIX=/usr/local

Alternative: single multicall binary with symlinks. Smaller footprint (~15× disk savings). The binary is installed setuid-root so that passwd, chfn, chsh, newgrp, gpasswd, sg, newuidmap and newgidmap can serve unprivileged callers; every other applet drops back to the caller's uid before it runs, so the privilege model is the same as the per-tool layout. Intended for container/embedded use cases.

sudo make install-multicall PREFIX=/usr/local

From a release archive

Each release publishes two archives, each with a .sha256 alongside. They contain the same shadow-rs multicall binary but are not interchangeable:

Archive libc Linking Use it for
uu_shadow-x86_64-unknown-linux-gnu.tar.gz glibc dynamic Any regular distribution. This is the full build.
uu_shadow-x86_64-unknown-linux-musl-static.tar.gz musl static Minimal containers and embedded images: local /etc/passwd, no directory service, no PAM stack.

The static archive has no runtime dependencies, and pays for that with three capabilities a static binary cannot have — none of them cosmetic for account tools:

  • No PAM. passwd cannot change a password interactively, and chfn / chsh are root-only (they authenticate the caller through PAM and fail closed without it).
  • No NSS. Users from LDAP, SSSD, Active Directory or systemd-userdb are invisible to the five tools that look up the calling user.
  • No yescrypt ($y$). The default password hash on Debian 12+ and Ubuntu 24.04 can be neither verified nor produced.

If any of those matter on the host, use the glibc archive. docs/PLATFORM-SUPPORT.md explains each gap, what still works, and how the archive is built.

Either archive ships a plain binary — nothing is installed, symlinked, or made setuid by extracting it. To deploy it the same way make install-multicall would:

tar xzf uu_shadow-x86_64-unknown-linux-gnu.tar.gz   # or the -musl-static one
sudo install -o root -g root -m 4755 \
    uu_shadow-*/shadow-rs /usr/local/bin/shadow-rs
for tool in passwd chfn chsh newgrp gpasswd sg chage chpasswd chgpasswd newusers \
            groupadd groupdel groupmod grpck pwck useradd userdel usermod vipw vigr \
            pwconv pwunconv grpconv grpunconv login newuidmap newgidmap expiry; do
    sudo ln -sf shadow-rs "/usr/local/bin/$tool"
done

Mode 4755 is what the four self-service applets 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.

Test

All builds and tests run inside Docker containers to isolate from the host system. Three distros are tested to catch libc and PAM differences:

docker compose run --rm debian cargo test --workspace    # Debian Trixie (glibc)
docker compose run --rm alpine cargo test --workspace    # Alpine (musl libc)
docker compose run --rm fedora cargo test --workspace    # Fedora (SELinux enforcing)

Lint

docker compose run --rm debian cargo clippy --workspace --all-targets -- -D warnings
docker compose run --rm debian cargo fmt --all --check

Architecture

Cargo workspace monorepo built on uucore:

src/bin/shadow-rs.rs     multicall binary (dispatches by argv[0])
        |
src/uu/{tool}/           individual tool crates (passwd, useradd, ...)
        |
   ┌────┴────┐
uucore    shadow-core    shared infrastructure + domain library

Tools use uucore for the standard uutils API (UResult, #[uucore::main], show_error!) and shadow-core for domain-specific functionality.

shadow-core provides:

  • File parsers for /etc/passwd, /etc/shadow, /etc/group, /etc/gshadow, /etc/login.defs, /etc/subuid, /etc/subgid
  • Atomic file writes (lock, write tmp, fsync, rename, unlock, invalidate nscd) that keep the file's mode, owner, group and SELinux label
  • PAM integration (feature-gated)
  • Username/groupname and field validation (no : or control characters can reach a record)
  • UID/GID allocation

Each tool crate exports uumain() and uu_app(), following uutils conventions exactly so a future merge is frictionless.

Docker Test Matrix

Target Base libc PAM SELinux
debian rust:latest (Trixie) glibc Linux-PAM headers
alpine rust:alpine musl Linux-PAM none
fedora fedora:latest glibc Linux-PAM enforcing

The alpine image tests musl with dynamic linking, PAM included. The static musl release archive is a different build — no PAM, no NSS, no yescrypt — and has its own CI job that builds it and runs shadow-core's unit tests against static musl on every pull request. See docs/PLATFORM-SUPPORT.md.

Credits

Security patterns from OpenBSD (ISC license). PAM integration patterns from sudo-rs (Apache-2.0/MIT). uutils infrastructure via uucore (MIT).

Contributing

See CONTRIBUTING.md for guidelines.

Important: uutils/shadow is developed under a strict GPL clean-room policy. Do not read, reference, or feed into an LLM any code from shadow-maint/shadow (GPL-2.0+). Reference only: POSIX specs, man pages, BSD-licensed implementations (FreeBSD, OpenBSD, musl), and sudo-rs.

License

uutils/shadow is licensed under the MIT License.

GNU shadow-utils is licensed under the GPL 2.0 or later.