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.
* 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.
* 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.
* 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
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).