From 4259dc7cd0a3009bf51528bd4e560b9c9b7a1c2a Mon Sep 17 00:00:00 2001 From: Pierre Warnier Date: Fri, 3 Apr 2026 15:16:41 +0200 Subject: [PATCH] docs: fix stale claims, add missing -p flag to usermod man page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - README: fix passwd flag count (17→16), add Landlock mention, add Hardened goal, update usermod description with -p, fix past tense - CONTRIBUTING: fix unsafe description to match actual workspace policy - CHANGELOG: add [Unreleased] section for post-v0.1.0 changes - OPENBSD-REFERENCE: move all implemented items to correct section - SECURITY-HARDENING: mark all 21 items as implemented - man/usermod: add -p/--password flag documentation --- CHANGELOG.md | 25 +++++++++++ CONTRIBUTING.md | 5 ++- README.md | 12 ++++-- docs/OPENBSD-REFERENCE.md | 76 +++++--------------------------- docs/SECURITY-HARDENING.md | 88 +++++++------------------------------- docs/man/usermod.8.md | 4 ++ 6 files changed, 66 insertions(+), 144 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bbe85f..1bbe1f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,31 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- `usermod -p/--password` flag for setting pre-hashed passwords (#114) +- End-to-end deployment tests in Docker: 117 assertions covering symlink + dispatch, setuid, PAM, Landlock, nscd, and Ansible interop (#102, #115) +- Docker multi-distro CI in GitHub Actions (debian, alpine, fedora) +- Shell completion generation for bash, zsh, fish (#106) +- Renovate for automated dependency updates +- `rust-toolchain.toml` for contributor convenience +- `feat_common_core` feature alias (all 14 tools) + +### Changed + +- Cargo.toml metadata aligned with uutils ecosystem conventions +- Tool crate descriptions normalized to `"tool ~ (shadow-rs) verb phrase"` format +- Edition 2024 consistently applied across root and workspace packages + +### Fixed + +- Password hash validation rejects `:`, `\n`, `\r` (field injection prevention) +- Error on missing shadow entry in usermod (was silent no-op) +- `days_since_epoch()` centralized in shadow-core (was duplicated) + ## [0.1.0] - 2026-03-24 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 660b6e2..6b0bb86 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -91,8 +91,9 @@ Utilities must be embeddable. Return `UResult<()>` from `uumain`. The ### `unsafe` -Only for FFI (nix crate for syscalls, PAM crate for PAM). Every `unsafe` block -must have a `// SAFETY:` comment explaining why it's sound. +Denied at the workspace level (`unsafe_code = "deny"`). Only two FFI boundary +modules are exempted: `shadow-core::pam` (PAM C library) and `shadow-core::crypt` +(POSIX crypt(3)). Every `unsafe` block must have a `// SAFETY:` comment. ### `str`, `OsStr` & `Path` diff --git a/README.md b/README.md index 824d492..aedb87a 100644 --- a/README.md +++ b/README.md @@ -22,8 +22,9 @@ passwords, and groups on every Linux system. 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). There is **no Rust -reimplementation** — not in uutils, not in Prossimo/Trifecta, not on crates.io. +subuid collision enabling account takeover). Until shadow-rs, there was **no +Rust reimplementation** — not in uutils, not in Prossimo/Trifecta, not on +crates.io. [sudo-rs](https://github.com/trifectatechfoundation/sudo-rs) proved the model: an independent Rust rewrite of a privilege-boundary tool can go from zero to @@ -42,6 +43,9 @@ default-in-Ubuntu in under 3 years. shadow-rs follows that playbook. - **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. @@ -49,11 +53,11 @@ default-in-Ubuntu in under 3 years. shadow-rs follows that playbook. | Tool | Status | |------|--------| -| `passwd` | **All 17 flags implemented.** Drop-in for GNU passwd. PAM password change, `--root`, `--quiet`, `--stdin`. Output bit-for-bit identical with GNU. | +| `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. | +| `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. | diff --git a/docs/OPENBSD-REFERENCE.md b/docs/OPENBSD-REFERENCE.md index 223a9a9..9c9916a 100644 --- a/docs/OPENBSD-REFERENCE.md +++ b/docs/OPENBSD-REFERENCE.md @@ -12,78 +12,24 @@ Source: `cvsweb.openbsd.org/src/usr.bin/passwd/` and `src/lib/libutil/passwd.c`. | Signal blocking during file writes | #38 — `SignalBlocker` RAII | | Privilege drop during PAM conversation | #39 — `PrivDrop` RAII | | Environment sanitization | #40 — `sanitize_env()` | -| Landlock stub | #41 — documented, needs crate dep | +| Landlock filesystem restriction | #41 — `apply_landlock()` in passwd | | Absolute paths for subprocesses | #20 — `/usr/sbin/nscd` | | Password zeroing | #7 — `zeroize` crate | | Secure temp file permissions | #19 — `0o600` from creation | | TOCTOU-resistant locking | #18 — lock-via-hard-link | +| Core dump suppression | #43 — `suppress_core_dumps()` in hardening.rs | +| Resource limit hardening | #44 — `raise_file_size_limit()` in hardening.rs | +| Zero-length output guard | #45 — in `atomic_write` | +| setuid(0) consolidation | #47 — before file operations in passwd | +| User enumeration prevention | #49 — constant-time PAM in passwd | +| SIGINT handler during password input | #48 — "Password unchanged" + terminal restore | +| Umask reset | #51 — `UmaskGuard` RAII in lock/tmp creation | ### Not Yet Implemented -#### CRITICAL: Core Dump Suppression -OpenBSD's `pw_init()` sets `RLIMIT_CORE` to 0. A core dump from a setuid -passwd process could expose password hashes and plaintext passwords. - -```rust -nix::sys::resource::setrlimit(Resource::RLIMIT_CORE, 0, 0)?; -``` - -Also: `prctl(PR_SET_DUMPABLE, 0)` prevents ptrace attachment. - -#### HIGH: Resource Limit Hardening -OpenBSD raises `RLIMIT_FSIZE` to infinity before file writes. A malicious -caller could `ulimit -f 1` before invoking setuid passwd, truncating -`/etc/shadow` mid-write. - -```rust -nix::sys::resource::setrlimit(Resource::RLIMIT_FSIZE, RLIM_INFINITY, RLIM_INFINITY)?; -``` - -#### HIGH: setuid(0) Before File Operations -OpenBSD calls `setuid(0)` before the critical section to consolidate both -real and effective UID to root. Some filesystem configurations check real UID. - -#### MEDIUM: Zero-Length Output Guard -OpenBSD checks that the output file is non-zero-length before replacing the -original. A zero-length `/etc/shadow` locks out all users. - -```rust -// In atomic_write, after the closure runs: -if tmp_file.metadata()?.len() == 0 { - return Err(ShadowError::Other("refusing to write zero-length file")); -} -``` - -#### MEDIUM: User Enumeration Prevention -OpenBSD rejects non-root callers targeting other usernames before PAM auth. -Our current flow lets PAM auth fail, potentially leaking timing information -about whether the account exists. - -#### MEDIUM: Clean SIGINT Handler During Password Input -OpenBSD's `kbintr` handler uses `_exit(0)` and `dprintf` (async-signal-safe). -Prints "Password unchanged." and exits cleanly. Our PAM EchoGuard may not -run its Drop destructor on signal-induced termination. - -#### LOW: Umask Reset -OpenBSD saves/restores umask around lock file creation. Defense-in-depth -against edge cases where umask interacts with file permissions. - -## Implementation Priority - -**Immediate** (before any release): -1. Core dump suppression — 5 lines -2. Resource limit hardening — 10 lines -3. Zero-length output guard — 5 lines in `atomic_write` - -**Next sprint**: -4. setuid(0) consolidation -5. User enumeration prevention -6. SIGINT handler for password input - -**Roadmap**: -7. Full Landlock implementation -8. seccomp-bpf filter -9. Umask handling +#### MEDIUM: Seccomp-BPF +Restrict syscalls to only what passwd needs after initialization. +Complex but effective — sudo-rs uses this approach. ## File References diff --git a/docs/SECURITY-HARDENING.md b/docs/SECURITY-HARDENING.md index be85bab..c40fd77 100644 --- a/docs/SECURITY-HARDENING.md +++ b/docs/SECURITY-HARDENING.md @@ -1,8 +1,8 @@ # Security Hardening Roadmap -Techniques to adopt from OpenBSD and best practices for setuid-root tools. +Techniques adopted from OpenBSD and best practices for setuid-root tools. -## Already Implemented +## Implemented - [x] `caller_is_root()` uses `getuid()` not `geteuid()` for authorization - [x] Atomic file writes with `fsync` + `rename` @@ -13,78 +13,20 @@ Techniques to adopt from OpenBSD and best practices for setuid-root tools. - [x] Absolute paths for subprocess execution (`/usr/sbin/nscd`) - [x] PAM delegation (no custom password hashing) - [x] `TmpGuard` drop pattern (no leaked temp files) +- [x] Signal blocking during file writes (#38 — `SignalBlocker` RAII) +- [x] Environment sanitization (#40 — `sanitize_env()`) +- [x] Privilege drop during PAM conversation (#39 — `PrivDrop` RAII) +- [x] Core dump suppression (#43 — `suppress_core_dumps()`) +- [x] Resource limit hardening (#44 — `raise_file_size_limit()`) +- [x] Zero-length output guard (#45 — in `atomic_write`) +- [x] setuid(0) consolidation (#47 — before file operations) +- [x] SIGINT handler during password input (#48 — "Password unchanged") +- [x] User enumeration prevention (#49 — constant-time PAM) +- [x] O_CLOEXEC on file descriptors (#50) +- [x] Umask reset (#51 — `UmaskGuard` RAII) +- [x] Landlock filesystem restriction (#41 — `apply_landlock()` in passwd) -## Phase 1: Quick Wins - -### Signal Blocking During File Writes -Block `SIGINT`/`SIGTERM`/`SIGHUP` during the critical section between -lock acquisition and lock release. Prevents partial shadow file updates. - -```rust -use nix::sys::signal::{SigSet, SigmaskHow, sigprocmask}; - -let mut oldset = SigSet::empty(); -let mut blockset = SigSet::empty(); -blockset.add(Signal::SIGINT); -blockset.add(Signal::SIGTERM); -blockset.add(Signal::SIGHUP); -sigprocmask(SigmaskHow::SIG_BLOCK, Some(&blockset), Some(&mut oldset))?; - -// ... critical section: lock, write, rename, unlock ... - -sigprocmask(SigmaskHow::SIG_SETMASK, Some(&oldset), None)?; -``` - -### Environment Sanitization -Clear the environment on startup for setuid binaries, keeping only: -- `PATH=/usr/bin:/bin` -- `TERM` -- `LANG`/`LC_*` - -```rust -fn sanitize_env() { - let keep = ["TERM", "LANG", "LC_ALL", "LC_MESSAGES"]; - let saved: Vec<_> = keep.iter() - .filter_map(|k| std::env::var(k).ok().map(|v| (*k, v))) - .collect(); - // Clear everything - for (key, _) in std::env::vars() { - std::env::remove_var(&key); - } - // Restore kept vars + safe PATH - std::env::set_var("PATH", "/usr/bin:/bin:/usr/sbin:/sbin"); - for (key, val) in saved { - std::env::set_var(key, val); - } -} -``` - -### Privilege Drop During PAM Conversation -Drop effective UID to caller's real UID during the PAM conversation, -re-elevate only for file writes: - -```rust -let caller_uid = nix::unistd::getuid(); -nix::unistd::seteuid(caller_uid)?; // drop privs -pam.authenticate(0)?; -pam.chauthtok(0)?; -nix::unistd::seteuid(Uid::from_raw(0))?; // re-elevate for file write -``` - -## Phase 2: Linux-Specific Hardening - -### Landlock (Linux 5.13+) -Restrict filesystem access to only the files we need: - -```rust -// Only allow: /etc/passwd, /etc/shadow, /etc/shadow.lock, /dev/tty -let ruleset = Ruleset::new() - .handle_access(AccessFs::ReadFile | AccessFs::WriteFile)? - .create()?; -ruleset.add_rule(PathBeneath::new(PathFd::new("/etc/")?, AccessFs::all()))?; -ruleset.add_rule(PathBeneath::new(PathFd::new("/dev/tty")?, AccessFs::all()))?; -ruleset.restrict_self()?; -``` +## Not Yet Implemented ### Seccomp-BPF Restrict syscalls to only what passwd needs after initialization. diff --git a/docs/man/usermod.8.md b/docs/man/usermod.8.md index 943ec95..a1e07b9 100644 --- a/docs/man/usermod.8.md +++ b/docs/man/usermod.8.md @@ -44,6 +44,10 @@ changes that are specified on the command line. **-L**, **--lock** : Lock the user's password by prepending a '!' to the shadow password. +**-p**, **--password** *PASSWORD* +: Set the user's password to the specified pre-hashed value. The hash + must not contain ':', '\\n', or '\\r' characters. + **-P**, **--prefix** *PREFIX_DIR* : Use *PREFIX_DIR* as a prefix for system file paths.