Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- `chgpasswd`, the seventeenth tool: it sets group passwords in batch from
stdin, the counterpart of `chpasswd` for groups. Every line is resolved and
hashed before any file is written, so a batch naming one group that does not
exist changes nothing. The hash goes to `/etc/gshadow` where that file
exists, with `x` left in `/etc/group`, and into `/etc/group` where it does
not — without creating a gshadow file, which would change how the rest of the
host reads group passwords. `-c NONE`, which GNU honours by storing the
password as clear text, is refused; `-e` already writes a field verbatim when
that is genuinely wanted

- `sg`, the sixteenth tool: it runs a single command with a different primary
group. `sg` and `newgrp` decide who may enter a group by the same rules and
enter it the same way — on a GNU system they are one binary reached through a
Expand All @@ -18,6 +28,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- `make check` also runs the suite as an unprivileged user, through the new
`make test-unprivileged`. Every container in `docker-compose.yml` runs as
root, so a test that silently assumed root passed locally and failed only in
CI -- and a test asserting a failure exit code passed there for the wrong
reason, since "permission denied" is a failure too

- `newgrp` no longer refuses a passwordless group before prompting. It asked
for a password only when the group had one, so whether a prompt appeared told
any caller which groups have passwords set — a list of the ones worth
Expand Down
12 changes: 12 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ members = [
"src/uu/chage",
"src/uu/gpasswd",
"src/uu/sg",
"src/uu/chgpasswd",
]

[workspace.package]
Expand Down Expand Up @@ -94,11 +95,12 @@ chsh = { optional = true, version = "0.4.0", package = "uu_chsh", path = "src/uu
newgrp = { optional = true, version = "0.4.0", package = "uu_newgrp", path = "src/uu/newgrp" }
gpasswd = { optional = true, version = "0.4.0", package = "uu_gpasswd", path = "src/uu/gpasswd" }
sg = { optional = true, version = "0.4.0", package = "uu_sg", path = "src/uu/sg" }
chgpasswd = { optional = true, version = "0.4.0", package = "uu_chgpasswd", path = "src/uu/chgpasswd" }

[features]
default = ["passwd", "pwck", "useradd", "userdel", "usermod", "chpasswd", "chage",
"groupadd", "groupdel", "groupmod", "grpck", "chfn", "chsh", "newgrp", "gpasswd",
"sg"]
"sg", "chgpasswd"]

# PAM authentication (requires libpam-dev). The `?` matters: without it,
# asking for PAM would drag in the three applets that can use it even when the
Expand Down Expand Up @@ -134,6 +136,7 @@ groupdel = { version = "0.4.0", package = "uu_groupdel", path = "src/uu/groupdel
groupmod = { version = "0.4.0", package = "uu_groupmod", path = "src/uu/groupmod" }
newgrp = { version = "0.4.0", package = "uu_newgrp", path = "src/uu/newgrp" }
sg = { version = "0.4.0", package = "uu_sg", path = "src/uu/sg" }
chgpasswd = { version = "0.4.0", package = "uu_chgpasswd", path = "src/uu/chgpasswd" }
passwd = { version = "0.4.0", package = "uu_passwd", path = "src/uu/passwd" }
useradd = { version = "0.4.0", package = "uu_useradd", path = "src/uu/useradd" }
userdel = { version = "0.4.0", package = "uu_userdel", path = "src/uu/userdel" }
Expand Down
32 changes: 29 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ SBINDIR ?= $(PREFIX)/sbin
SETUID_TOOLS = passwd chfn chsh newgrp gpasswd sg

# Root-only tools (no setuid; fail at getuid() check for non-root callers).
ROOT_TOOLS = useradd userdel usermod chpasswd \
ROOT_TOOLS = useradd userdel usermod chpasswd chgpasswd \
groupadd groupdel groupmod pwck grpck

# Tools an ordinary user runs, and which therefore go in bin rather than sbin:
Expand All @@ -20,7 +20,7 @@ USER_TOOLS = $(SETUID_TOOLS) chage

ALL_TOOLS = $(SETUID_TOOLS) $(ROOT_TOOLS) chage

.PHONY: all build build-multicall build-arm64 dist-musl check test test-gnu-compat test-arm64 install install-multicall uninstall clean
.PHONY: all build build-multicall build-arm64 dist-musl check test test-gnu-compat test-unprivileged test-arm64 install install-multicall uninstall clean

all: build

Expand Down Expand Up @@ -71,6 +71,7 @@ check:
cargo clippy --workspace --all-targets --features pam -- -D warnings
cargo clippy --workspace --all-targets --all-features -- -D warnings
$(MAKE) test
$(MAKE) test-unprivileged

# `install` ships binaries built with pam, so the tests must cover that build
# as well as the default one: the feature changes which code paths exist.
Expand Down Expand Up @@ -102,12 +103,37 @@ build-arm64:
test-arm64: build-arm64
ARM64_BIN=$(ARM64_BIN) bash tests/arm64-smoke.sh

# Run the suite as an unprivileged user.
#
# Every container in docker-compose.yml runs as root, so a test that silently
# assumes root passes here and fails in CI, where one job runs as an ordinary
# user -- and a test asserting a failure exit code passes for the wrong reason,
# because "permission denied" is also a failure. This target is that job,
# locally. The binaries are already built, so it only re-runs them.
#
# SHADOW_TEST_REQUIRE_ROOT is cleared deliberately: it exists to turn a skip
# into a failure when the suite *is* running as root, which is the opposite of
# what this target does.
UNPRIV_USER = shadowtest

test-unprivileged:
cargo test --workspace --no-run 2>&1 \
| sed -n 's/.*(\(target\/debug\/deps\/[^)]*\))$$/\1/p' > /tmp/test-binaries
@id -u $(UNPRIV_USER) >/dev/null 2>&1 || useradd -m $(UNPRIV_USER)
@chmod -R a+rX target
@fail=0; while read -r bin; do \
printf '== %s\n' "$$bin"; \
su $(UNPRIV_USER) -s /bin/sh -c "SHADOW_TEST_REQUIRE_ROOT= $(CURDIR)/$$bin" \
|| fail=1; \
done < /tmp/test-binaries; \
exit $$fail

# Compare our output and exit codes against the GNU tools installed alongside.
# Needs root and the GNU shadow package, so it belongs in a container.
test-gnu-compat:
bash tests/gnu-compat.sh

# Default install: 16 standalone per-tool binaries, with the setuid layout and
# Default install: 17 standalone per-tool binaries, with the setuid layout and
# the bin/sbin split GNU shadow-utils uses. Only $(SETUID_TOOLS) are setuid.
install: build
@for tool in $(SETUID_TOOLS); do \
Expand Down
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ default-in-Ubuntu in under 3 years. This project follows that playbook.
| `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. |

## Building

Expand All @@ -89,9 +90,9 @@ docker compose run --rm debian cargo build --release

### Install

Default install: 16 standalone per-tool binaries with least-privilege setuid
Default install: 17 standalone per-tool binaries with least-privilege setuid
layout matching GNU shadow-utils. Only `passwd`, `chfn`, `chsh`, `newgrp`,
`gpasswd` and `sg` are installed setuid-root; the other 10 are plain `0755`.
`gpasswd` and `sg` are installed setuid-root; the other 11 are plain `0755`.

```shell
sudo make install PREFIX=/usr/local
Expand Down Expand Up @@ -142,8 +143,8 @@ 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 groupadd groupdel \
groupmod grpck pwck useradd userdel usermod; do
for tool in passwd chfn chsh newgrp gpasswd sg chage chpasswd chgpasswd groupadd \
groupdel groupmod grpck pwck useradd userdel usermod; do
sudo ln -sf shadow-rs "/usr/local/bin/$tool"
done
```
Expand Down
141 changes: 141 additions & 0 deletions docs/man/chgpasswd.8.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
# chgpasswd(8) - update group passwords in batch mode

## NAME

chgpasswd - update group passwords in batch mode

## SYNOPSIS

**chgpasswd** [*options*]

## DESCRIPTION

The **chgpasswd** command reads a list of group and password pairs from
standard input and uses it to update a set of existing groups. It is
**chpasswd**(8)'s counterpart for groups.

Each line is of the form:

```
group_name:password
```

Only the first colon separates the two fields, so a password may itself
contain colons — though one written into /etc/gshadow would split the line and
corrupt the file, and is refused.

By default the supplied passwords are in clear text and are hashed before
being stored. The scheme comes from **ENCRYPT_METHOD** in /etc/login.defs, so
group passwords are hashed the same way as everything else on the host.

## ALL OR NOTHING

Every line is parsed, every named group is resolved, and every password is
hashed **before** any file is written. A batch naming one group that does not
exist changes nothing at all, rather than applying the lines before the bad
one and stopping.

The account files are then written in one locked transaction, so a concurrent
**gpasswd**(1) or **groupmod**(8) cannot interleave with it.

## WHERE THE PASSWORD IS STORED

On a system with /etc/gshadow, the hash is written there and the group's
password field in /etc/group is set to `x`, which is what marks the password
as living in the shadowed file. /etc/group is world-readable; /etc/gshadow is
not.

On a system without /etc/gshadow, the hash is written into /etc/group itself.
**chgpasswd** does not create a gshadow file: doing so would change how every
other tool on the host reads group passwords.

A group present in /etc/group with no /etc/gshadow line gets one, carrying the
membership /etc/group already records.

## OPTIONS

**-c**, **--crypt-method** *METHOD*
: Use *METHOD* to hash the passwords instead of the configured default.
Supported: **SHA256**, **SHA512**, **YESCRYPT**.

**-e**, **--encrypted**
: The supplied passwords are already hashed and are stored verbatim. This is
the only mode that may write an empty field, which is how a group password
is cleared.

**-m**, **--md5**
: Rejected. See DIFFERENCES FROM GNU SHADOW below.

**-R**, **--root** *CHROOT_DIR*
: Apply changes in *CHROOT_DIR* and use its configuration files.

**-s**, **--sha-rounds** *ROUNDS*
: Iteration count for the SHA-2 schemes. Requires **-c**: a rounds count
without a scheme that takes one is meaningless, and ignoring it silently
would write a password the caller did not ask for.

**-P**, **--prefix** *PREFIX_DIR*
: Read and write the account files under *PREFIX_DIR* without chrooting.

## DIFFERENCES FROM GNU SHADOW

**-m** and **-c MD5**, and **-c DES**, are refused rather than honoured. Both
schemes are broken, and a group password hashed with either is worth little
more than none at all.

**-c NONE** is refused. GNU accepts it and stores the password as clear text
in /etc/gshadow. If a field really is to be written verbatim, **-e** does that
explicitly.

An empty password in plaintext mode is refused: hashing an empty string
produces a valid hash that a bare Enter matches, which is a group anyone can
enter, not a group with no password.

## EXIT STATUS

**0**
: Success. Empty input succeeds having done nothing.

**1**
: The passwords could not be changed. Nothing was written.

**2**
: Invalid command syntax.

**3**
: The **--root** directory could not be entered.

## FILES

/etc/group
: Group account information.

/etc/gshadow
: Secure group account information.

/etc/login.defs
: Shadow password suite configuration, read for **ENCRYPT_METHOD**.

## EXAMPLES

Set one group password:

```
# echo 'staff:correct horse battery staple' | chgpasswd
```

Apply a batch from a file, choosing the scheme:

```
# chgpasswd -c SHA512 < group-passwords.txt
```

Clear a group's password:

```
# echo 'staff:' | chgpasswd -e
```

## SEE ALSO

chpasswd(8), gpasswd(1), group(5), gshadow(5), login.defs(5), newgrp(1), sg(1)
4 changes: 4 additions & 0 deletions src/bin/completions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ fn get_tool_app(name: &str) -> Option<Command> {
"chage" => Some(chage::uu_app()),
#[cfg(feature = "chfn")]
"chfn" => Some(chfn::uu_app()),
#[cfg(feature = "chgpasswd")]
"chgpasswd" => Some(chgpasswd::uu_app()),
#[cfg(feature = "chpasswd")]
"chpasswd" => Some(chpasswd::uu_app()),
#[cfg(feature = "chsh")]
Expand Down Expand Up @@ -66,6 +68,8 @@ fn all_tool_names() -> Vec<&'static str> {
names.push("chage");
#[cfg(feature = "chfn")]
names.push("chfn");
#[cfg(feature = "chgpasswd")]
names.push("chgpasswd");
#[cfg(feature = "chpasswd")]
names.push("chpasswd");
#[cfg(feature = "chsh")]
Expand Down
25 changes: 21 additions & 4 deletions src/bin/shadow-rs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,13 @@ const SETUID_APPLETS: [&str; 6] = ["passwd", "chfn", "chsh", "newgrp", "gpasswd"
// nothing, and the binding is then not mutated.
#[allow(unused_mut)]
fn applets() -> Vec<(&'static str, Applet)> {
let mut table: Vec<(&'static str, Applet)> = Vec::with_capacity(16);
let mut table: Vec<(&'static str, Applet)> = Vec::with_capacity(17);
#[cfg(feature = "chage")]
table.push(("chage", |a| chage::uumain(a.iter().cloned())));
#[cfg(feature = "chfn")]
table.push(("chfn", |a| chfn::uumain(a.iter().cloned())));
#[cfg(feature = "chgpasswd")]
table.push(("chgpasswd", |a| chgpasswd::uumain(a.iter().cloned())));
#[cfg(feature = "chpasswd")]
table.push(("chpasswd", |a| chpasswd::uumain(a.iter().cloned())));
#[cfg(feature = "chsh")]
Expand Down Expand Up @@ -230,9 +232,24 @@ fn print_available_utils() {
mod tests {
use super::*;

const ALL_TOOLS: [&str; 16] = [
"chage", "chfn", "chpasswd", "chsh", "gpasswd", "groupadd", "groupdel", "groupmod",
"grpck", "newgrp", "passwd", "pwck", "sg", "useradd", "userdel", "usermod",
const ALL_TOOLS: [&str; 17] = [
"chage",
"chfn",
"chgpasswd",
"chpasswd",
"chsh",
"gpasswd",
"groupadd",
"groupdel",
"groupmod",
"grpck",
"newgrp",
"passwd",
"pwck",
"sg",
"useradd",
"userdel",
"usermod",
];

// The table drives both dispatch and `--list`, so it must contain only
Expand Down
Loading