mirror of
https://github.com/uutils/shadow.git
synced 2026-10-16 02:48:14 -07:00
0.5.0
15
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
aa987bb390 |
newuidmap, newgidmap: set the id maps of a user namespace (#298)
The setuid helpers that write /proc/<pid>/uid_map and gid_map: what Podman and rootless Docker call for every container they start. The kernel lets an unprivileged process map exactly one id into a namespace it created, its own; everything a container needs beyond that takes the privilege these carry, and they hand it out only within the ranges /etc/subuid and /etc/subgid grant the caller. Fedora ships them in shadow-utils; with these two, every tool the two reference distributions ship from the suite exists here. What is checked before anything is written was established by running the GNU helpers against a real user namespace, never by reading them: the operands must be a target and plain-decimal triples, a zero count is the overflow message; every outside range must sit inside one granted range -- an entry by login name or by numeric uid -- or be the caller's own id mapped once; root is exempt from neither, as newuidmap(1) says; and the target process must belong to the caller by real uid, account uid and the owner of /proc/<pid>, with all three named when they disagree. Overlapping ranges are refused by name; the kernel would refuse them with a bare EINVAL, so the message costs nothing and changes no accepted outcome. The map file is opened through a descriptor held on /proc/<pid>, so a pid recycled between the check and the write cannot redirect it, and the `fd:N` form takes such a descriptor from a caller that did its own checks. It is re-opened through /proc/self/fd rather than adopted, which keeps the tool free of unsafe. The tests do the real thing. A test user creates a namespace with unshare and the helper runs as that user, unprivileged, against it: the own-id mapping lands and the kernel's uid_map reads it back, the second write is refused by the kernel and reported, the fd form works, and every refusal is checked against the same live target. That runs on glibc and musl alike. The Debian image gains the uidmap package so tests/gnu-compat.sh can hold the helpers against GNU's. They take no --help -- any bad command line is the usage text and exit 1 -- so the comparison is on the refusals the two share rather than on help. Verified: make check clean with the unprivileged run; 912 tests on alpine and 934 on fedora with PAM; 393 e2e assertions against a real install, the kernel-visible writes included; 57 GNU comparisons; 34 arm64 assertions under emulation. |
||
|
|
4ac1bdfe32 |
login: begin a session on the system (#297)
* shadow-core: take /etc/.pwd.lock before the per-file lock FileLock took the per-file `.lock` first and `/etc/.pwd.lock` second. Two tools working on different files at once could then meet in the classic shape: one holding passwd.lock and waiting for .pwd.lock, the other holding shadow.lock and waiting for passwd.lock's holder to let go of .pwd.lock. Neither can move; both sit out the fifteen-second timeout; both fail -- on a system that was merely busy. Sampling the lock files every 250 ms while the whole suite ran showed it plainly: a chpasswd held shadow.lock for 28 s and a useradd held passwd, group and gshadow for 14 s, each waiting on the other, and seven tests failed with "timed out" on one lock or the other. Three runs after the change: no failures, and the suite back from 35-50 s to 17 s. .pwd.lock now comes first, the order lckpwdf(3) and the GNU tools use and the only one that cannot deadlock: whoever holds it is the only process asking for per-file locks, so it never waits for anyone who is waiting for it. If the per-file lock then cannot be taken, the .pwd.lock reference is given back, so nothing is held on failure. * useradd: lock the shadow file with the others, and write it first useradd wrote /etc/passwd and the group files in one transaction, then opened /etc/shadow on its own afterwards -- under a comment claiming the first locks were still held, which they were not: commit_all releases them. A shadow lock that timed out at that point left the account half made: a passwd line with no shadow line, which a second useradd then reported as already existing. That is exactly what turned up when the login tests put the suite under load. The shadow file is now locked with passwd, group and gshadow before anything is written, and written first, for the reason pwconv writes shadow first: a failure between the writes must never leave a passwd line whose hash is nowhere. The old write_shadow_entry helper stays for its unit test only. A regression test puts a directory where the shadow file should be and checks that useradd fails with /etc/passwd untouched. Before this change the passwd line was written and the failure came after. * login: begin a session on the system login is the twenty-fifth tool and the one uutils/login was archived towards. It is what getty runs on a terminal: it prompts for a name and, through the `login` PAM service, a password; refuses after LOGIN_RETRIES failures or LOGIN_TIMEOUT seconds; opens the PAM session and takes its environment; records the login in utmp and wtmp; hands the terminal to the user; runs the login shell as the user; and when the shell exits, closes the session and records the logout. Which login to match was a question. Debian 13 and Ubuntu 25.10 take login from util-linux, but every release in service -- Ubuntu 20.04, 22.04 and 24.04, Debian 12 -- ships shadow's; the two were run side by side on real pseudo-terminals and agree on everything that matters. This implements the option set they share (-p, -f, -h, name) plus util-linux's -H and --help, which conflict with nothing. There is no -h for help: -h has been the remote host since telnetd. -r, the rlogin protocol, is refused. util-linux's vhangup is not reproduced; shadow's login, which the installed base runs, does not do it either. A wrong password and an unknown name get the same words, after the same delay. -f skips authentication and with it the auth stack, pam_nologin included, as both tools do. A missing home falls back to / with a notice when DEFAULT_HOME allows. The shell runs as a child so the session can be closed afterwards; it gets a clean signal mask, login keeps its own, and groups, gid and uid are dropped in that order in one pre_exec. shadow-core grew what login needed and nothing had: pam setcred, open_session, close_session, user and environment; utmp/wtmp recording; getpwnam through NSS; a getty-style spawn_with_controlling_tty; spawn_as_user; and a watchdog. Without the pam feature the applet says PAM is not compiled in and exits: a login that cannot authenticate is not a login, and the static musl archive is documented accordingly. The tests drive the real binary on a pseudo-terminal the way getty would, answering each prompt once it appears: autologin, the environment with and without -p, a wrong then a right password, an unknown user, the retry limit, the missing home, and the terminal handover. Writing them under the full parallel suite is what exposed the two fixes committed before this one. Verified: make check clean with the unprivileged run; 892 tests on alpine and 882 on fedora; 368 e2e assertions against a real install, including the password path on a pty; 52 GNU comparisons; 32 arm64 assertions under emulation. * docker: give the Fedora image the login PAM service Fedora ships /etc/pam.d/login in util-linux, which the test image did not install. With no service file PAM falls back to `other`, which on Fedora denies everything: `login -f` failed its account check and the password path was refused before any prompt, so every login test failed there and only there. util-linux is now installed, which is what any real Fedora has. The login tests then run through Fedora's own stack -- pam_selinux, pam_loginuid, pam_namespace -- and pass, which makes it the second PAM configuration the session code is exercised against rather than a gap. |
||
|
|
921f8551f6 |
Update fedora Docker tag to v46 (#274)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> |
||
|
|
acc1771bc2 |
tests: exercise the PAM stack against known permit and deny services (#281)
The only PAM coverage was the deployment image authenticating a real account. That cannot exercise a *refusal* without relying on a wrong password being wrong, and it cannot tell a refusal apart from a conversation that never reached the stack -- which is exactly the failure that broke password changes earlier this week and took three rounds to find. The glibc images now define two throwaway services, one backed by pam_permit and one by pam_deny, and shadow-core tests the wrapper against both: the permit stack authenticates and passes account management, the deny stack refuses with the stack's own answer rather than a conversation error, and a service with no configuration at all does not authenticate. The tests skip where the services are absent, which is any machine but the containers. CI also gains an all-features clippy pass. The completions feature and the shadow-core feature gates were compiled by no job, so a lint or a compile error in them could reach main unnoticed. make check runs it too. |
||
|
|
9cd5eef4ac |
Install layout, aarch64 archives, pinned images, and a pre-push hook worth keeping (#272)
* hooks: stop reproducing CI in pre-push The hook ran fmt, clippy and the test suite on Debian, Alpine and Fedora in series, on every push. That is several minutes for a push that may carry nothing: pushing the 0.2.2 tag moved one object and still blocked for over two minutes. The predictable result was that every push in the last batch of work used --no-verify, and a hook that is always skipped protects nothing while still costing time whenever someone forgets to skip it. It now runs the workspace tests once, on Debian, and returns immediately for a push that carries no new commits -- a tag, a branch deletion, or a branch the remote already has. git hands the hook one line per ref on stdin; the ranges it names are asked for a single commit, and if there are none there is nothing to test. Everything the hook dropped is already a required check on the pull request, running in parallel with retries: fmt, clippy and the tests with and without pam, MSRV, cargo-deny, the static musl archive, the three-distro matrix as root, and the end-to-end deployment image. Closes #231. * install: put the user-facing tools in bin, not sbin All fourteen binaries went to $(PREFIX)/sbin, which is not on a normal user's PATH: passwd was 'command not found' for exactly the people it is setuid for. GNU shadow splits them, verified against the Debian package -- passwd, chage, chfn, chsh and newgrp in /usr/bin, the other nine in /usr/sbin. Both install targets do the same now, and the multicall symlinks in bin point at the absolute path of the binary in sbin. chage is in bin for 'chage -l', the one mode a user may run on their own account. The deployment suite follows each tool to the directory it belongs in, and asserts that all five are reachable by name for an unprivileged user, which is the property the split exists for. make test also runs the suite with pam now. install ships binaries built with it, so a run without it covered a build nobody ships. * docker: pin the base images, drop the unused nextest download rust:latest, rust:alpine and fedora:latest meant a new toolchain or distro release landed on main with no pull request and no signal, and clippy gains lints between Rust releases. They are pinned to what the images resolve to today -- rust 1.98 on Trixie and Alpine 3.23, Fedora 44 -- and Renovate's Dockerfile manager proposes the bumps, so a toolchain change arrives as a reviewable PR that CI has run. Each image also downloaded cargo-nextest from get.nexte.st/latest on every build: unpinned, unverified, and never invoked -- nothing in the repository runs 'cargo nextest'. cargo-deny, which is used, keeps its pinned version and checksum. * deny: allow exactly the licences the graph uses The allow list carried ISC, BSD-2-Clause, CC0-1.0 and Zlib, none of which appears anywhere in the dependency graph. A list wider than the graph means a crate arriving under a new licence passes silently; the point of the check is that such an arrival is a decision someone records. It is now MIT, Apache-2.0, BSD-3-Clause (subtle, for the constant-time comparison in shadow-core::crypt) and Unicode-3.0 (tinystr and unicode-ident, via uucore's fluent stack), taken from 'cargo deny --all-features list'. Two crates carry a copyleft option in a dual licence and are accepted under the other half; that is now written down rather than left for the next reader to work out. All four checks pass with the narrower list. * ci: run the advisory workflow on a schedule only It also ran on every push touching a manifest or the lockfile, where the Cargo Deny job in ci.yml had already checked the same advisory database alongside licences and bans. Two tools reporting the same finding on the same commit is noise. What the pull request job cannot do is notice an advisory published against code nobody has touched, which is the reason to keep a scheduled run. That is now all this workflow does, plus manual dispatch. * packaging: remove the RPM spec and debian directory Neither can build. debian/ has no changelog at all, so dpkg-buildpackage stops before it starts: dpkg-buildpackage: error: cannot open file debian/changelog and shadow-rs.spec pins version 0.2.0 against a 0.2.2 workspace, points at the pre-transfer repository URL, and lists a %files layout that 'make install' does not produce -- it names the multicall binary, which only install-multicall builds, and puts every tool in sbin, which is no longer where half of them go. Shipping packaging that claims to work and does not is worse than shipping none, and this would have produced a package declaring 'Provides: passwd, login' on a system whose real passwd it replaced. Releases are the archives dist publishes; a distribution packager writes these against their own policy anyway. * release: publish an aarch64 archive The pipeline shipped x86_64 only. arm64 servers and Apple-silicon containers are common enough that a missing archive is a real gap. It builds on a native arm64 runner rather than cross-compiling, which is what makes it straightforward: shadow-core::crypt links crypt(3) and the pam feature links libpam, so a cross build would need an arm64 sysroot carrying both, while a native runner installs them from apt like any other build. dist has picked a native runner for aarch64 since 0.30. Both runners are pinned to 24.04. dist's defaults are ubuntu-22.04 and ubuntu-22.04-arm, and 22.04 enters deprecation on 2026-09-17 with the image unsupported from 2027-04-17; the pin keeps the two archives on the same generation and off an image that is about to start failing. The higher runner raises the glibc floor for the dynamically linked archives, which is what the static musl archive is there for. release.yml is regenerated by 'dist generate'; 'dist plan' now lists three archives and resolves ubuntu-24.04-arm for the arm64 job. Closes #222. |
||
|
|
4e539357a1 |
shadow-core: remove duplicated lookups, fix login.defs radices, allocate around NSS (#271)
* shadow-core: delete the duplicated NSS entry type and the unused surface process::PwEntry was a field-by-field twin of passwd::PasswdEntry, copied across at every NSS lookup. getpwuid now returns the parser's own type: an NSS entry and a file entry describe the same account, and a second struct for it only bought a copy per call. lookup_username goes with it, and lookup_username_by_uid becomes one line. The tty module had its own signal-mask guard beside hardening's SignalBlocker, and the PAM conversation had its own controlling-terminal lookup. There is now one of each. error::ShadowResult and ShadowError::Permission had no users at all, and display_message carried an _is_error parameter it never read. harden_process returned a sanitized environment that every one of its thirteen callers discarded, which read as though the process environment had been dealt with. It has not been and is not: the in-process PAM and NSS modules still see what the caller set. The function no longer claims otherwise, and the callers no longer bind a value to throw away. Adds gid_exists, the group-side companion to getpwuid, for the allocator. * shadow-core: read login.defs numbers in the radices the man page documents login.defs(5) writes numbers 'in decimal, or in octal with a leading 0, or in hexadecimal with a leading 0x'. get_i64 parsed decimal only, which is not a rejection but a silent misreading: UID_MIN 01000 means 512 and came back as 1000, so useradd would allocate from a range the administrator had reserved. UMASK 022 and HOME_MODE 0750 have the same shape and will be read the day they are honoured. LoginDefs also gains Default, an empty configuration, which is what a caller wants when the file is missing: every lookup returns None and the tool falls back to its own default. * useradd, groupadd: never allocate an ID the name service already knows /etc/passwd is not the only source of accounts. An ID that is free in the file may belong to an LDAP, SSSD or systemd-homed user, and handing it out gives two accounts one ID -- which the kernel cannot tell apart, so the new local user ends up owning the directory user's files. Allocation now asks NSS as well as the file. Which sources count is an explicit Scope rather than a hidden default, because a --prefix or --root run manages another system's files: the name service running here describes this one, so asking it would skip IDs that are free on the target and claim none that are taken there. SysRoot::is_prefixed decides. uid_range and gid_range were the same function twice over different keys and are now one, the system ranges being the same keys with a SYS_ prefix. The allocation tests moved from next_uid to next_free. Once next_uid consults NSS its answer depends on which accounts exist on the machine running the test, and on a typical container 1000 and 1001 do. The policy is tested where it lives, and two tests assert the property that actually matters against the live system: an allocated ID is in range, not in the entries given, and unknown to NSS. * audit: write to /dev/log instead of forking logger for every event Each account event spawned /usr/bin/logger and waited for it, to write a few dozen bytes to a socket. A setuid-root tool should spawn as little as it can: the fork inherited the process state, depended on a binary being installed at a fixed path, and blocked the caller. The record now goes straight to /dev/log as an RFC 3164 datagram, and carries the terminal it came from instead of a literal '?'. Failure stays silent in every direction. A machine with no syslog daemon is a normal machine, and a record that cannot be written must not fail the operation that produced it. The deployment suite runs rsyslogd and greps the log for the creation and deletion records, so this is covered rather than assumed. Also corrects atomic's claim that its temp-file names are unique 'across threads' in a single-threaded crate; what they separate is successive writes within one process, which usermod does four of in a row. * docs: record the hardening changes and what --root really does sysroot's module documentation said every tool offering --root performs a real chroot. Five of the thirteen do; the other eight fold it into the same resolver --prefix uses, so an absolute path read out of a record resolves against / rather than the target. That is a real divergence worth its own change and is filed as #270; the documentation now says which tools do which and points there. * cargo: only gate what changes linkage; stop forcing applets in with pam Five of shadow-core's features -- shadow, group, gshadow, login-defs, subid -- gated modules that pull in no dependency. Gating them bought nothing and cost real confusion: cargo test -p shadow-core compiled a fraction of the crate and reported a pass for it. It runs 181 tests now where it ran 20, without a single one being written. uid_alloc loses its cfg(all(group, login-defs)) with them. What remains behind a feature is what actually changes the build: pam (links libpam), crypt (links libcrypt on glibc, and now pulls subtle, which nothing else uses) and landlock. In the root manifest, pam = ["passwd/pam", ...] force-enabled the three applets that can use PAM, so asking for PAM in a build that had deliberately left them out dragged them back in. The optional-dependency form pam = ["passwd?/pam", ...] applies the feature if the applet is there and does nothing if it is not. The feat_* aliases and the feat_phase* groups are gone. Every one was a rename of a feature cargo already provides -- feat_passwd = ["passwd"] -- and nothing outside the manifest had referred to them since the phases they were named for finished. Verified against all four delivery paths: make build and make build-multicall still link libpam, the static musl archive still builds and reports all fourteen applets, and a --no-default-features --features pam build now produces a binary with no applets at all and no warnings, which is the case the ? was added for. |
||
|
|
382edd4dc2 |
passwd: make the interactive password change work; repair and CI the e2e suite (#251)
* passwd: make the interactive password change work Three faults kept passwd from changing a password, all found by running a real change as an unprivileged user under su -c and read from pam_unix's own debug log: - the PAM conversation only ever opened /dev/tty, which fails with ENXIO when there is no controlling terminal (su -c, a service, cron); pam_unix then reported "conversation failed ... (old) token not obtained". It now falls back to stderr for the prompt and stdin for the answer, the way getpass(3) and PAM's misc_conv do, and still prefers /dev/tty when there is one; - pam_authenticate was called against the passwd service, which defines no auth stack, so it failed before any prompt. The current password is verified by pam_unix inside pam_chauthtok, keyed on the real uid; the call is removed; - the privilege drop spanned pam_chauthtok, so pam_unix could neither run its setgid helper nor rewrite /etc/shadow. euid 0 is what the setuid bit is for; the drop is gone. Landlock is no longer applied to the PAM path: restrict_self sets no_new_privs, which strips the setgid bit from unix_chkpwd. It now wraps only the direct-file paths, with rules that let NSS, nscd and logger run. --prefix becomes root-only, like --root. * e2e: repair the deployment image and run it in CI Dockerfile.e2e ran `make install`, which since #138 installs per-tool binaries, then chmod 4755'd a shadow-rs multicall that was never installed, so the image had not built for months. It now uses install-multicall, and an `e2e` CI job builds and runs the suite on every pull request. The suite now changes a password through PAM as an unprivileged user and proves it by authenticating with the new password and failing with the old (root's own su never prompts, so the old checks proved nothing), rejects a wrong current password, has root set one without the old, checks the setuid privilege model for pwck/grpck and that /etc/shadow keeps its owner and mode. tests/pam-e2e.sh, which could not fail, is deleted. * docs: record the passwd PAM fix and the e2e CI job |
||
|
|
d3bf29af78 | chore(deps): update debian docker tag to trixie-20260713 | ||
|
|
e3521be9f2 | chore(deps): update debian docker tag to trixie-20260623 | ||
|
|
6a50b777d1 | chore(deps): update debian docker tag to trixie-20260610 | ||
|
|
04427511e1 |
docker: install cargo-deny as a pinned prebuilt binary (#175)
The dev images built cargo-deny from source via `cargo install cargo-deny`.
That compiled cargo-deny on every image build (multi-minute) and surfaced
cargo-deny's own `profile.dev.package.{insta,similar}` warnings, which look
like our config but are not.
Install the pinned (0.19.8), checksum-verified prebuilt binary instead. The
static musl build runs on glibc too, so all three images share one asset.
Result: no source compile, no spurious warnings, and a reproducible,
sha256-verified install (vs the previous unpinned `cargo install`). Each
build self-checks with `cargo-deny --version`.
Version/checksum are ARGs so they can be bumped (and tracked by Renovate).
|
||
|
|
a47b357bc5 | chore(deps): update debian docker tag to trixie-20260518 | ||
|
|
af8c607dd7 | chore(deps): update debian docker tag to trixie-20260421 | ||
|
|
e29b06c461 |
e2e: add end-to-end deployment tests (#102)
Add automated deployment testing in Docker that simulates a real system-wide installation: build from source, install replacing GNU shadow-utils, then run 97 assertions covering: - Symlink dispatch and multicall dispatch (all 14 tools) - Setuid bits and non-root privilege enforcement - Full user lifecycle (useradd → chpasswd → usermod → pwck → userdel) - Full group lifecycle (groupadd → groupmod → groupdel → grpck) - Individual tool tests (chage, chfn, chsh, passwd -l/-u) - PAM authentication (su with known password via expect) - nscd cache invalidation - Landlock sandboxing (when kernel supports it) - Ansible integration (user/group modules as real-world consumer) New files: - docker/Dockerfile.e2e — multi-stage build + debian:trixie runtime - tests/e2e/deploy-test.sh — bash test harness with 97 assertions - tests/e2e/ansible-test.yml — Ansible playbook integration test - .dockerignore — prevent sending target/ and .git/ to Docker Modified: - docker-compose.yml — add e2e service (no changes to existing services) - Cargo.toml — add pam feature forwarding to root package - shadow-core/pam.rs — add #[link(name = "pam")] for linker Closes #109, closes #110, closes #111, closes #112, closes #113 Refs #102 |
||
|
|
97130fea20 |
scaffold: workspace, shadow-core, passwd tool, Docker test matrix
Cargo workspace with three-layer architecture mirroring uutils conventions: - shadow-core: /etc/passwd and /etc/shadow parsers, atomic file writes, username validation, error types, feature-gated module stubs (PAM, SELinux, group, gshadow, login.defs, subid, nscd, lock, uid_alloc) - uu_passwd: tool skeleton with clap CLI, uumain/uu_app pattern - multicall binary: argv[0] dispatch with --list support Docker test matrix (Debian Trixie, Alpine musl, Fedora SELinux): all three pass clippy -D warnings, 21 tests, and cargo fmt --check. 21 unit tests covering: - passwd/shadow file parsing and roundtrip serialization - atomic file write with fsync + rename + failure rollback - username validation rules (length, charset, edge cases) - clap command definition |