A memory-safe Rust reimplementation of D. J. Bernstein's checkpassword
(the checkpassword interface used by
qmail-pop3d, dovecot, and others). It is a drop-in sibling of the C fork
classic-checkpassword — same wire protocol, same
exit codes — with the DJB C libraries and build system replaced by the Rust
standard library and Cargo.
It reads a login, password, and timestamp on file descriptor 3, verifies the
credentials against the system password/shadow database, and — on success —
drops privileges and execs the program named in its arguments.
login \0 password \0 timestamp \0 ... (on fd 3)
| Exit | Meaning |
|---|---|
0 |
success — never returns; execs argv[1] argv[2] … |
1 |
bad credentials |
2 |
misuse (no subprogram, malformed/oversized input) |
111 |
temporary failure (read error, ETXTBSY, chdir failure) |
Verification uses the system crypt(3) (libxcrypt) rather than a Rust
reimplementation of the hash. On Debian 13 the shadow default is yescrypt
($y$…), a stacked, correctness-brittle KDF (scrypt + pwxform S-box + custom
base64 MCF) whose failure mode is silent: a subtle bug rejects valid users or
mis-accepts, and no compiler catches it. libxcrypt is the audited implementation
that wrote those hashes in the first place, so it is both the safer and the
smaller choice — and it supports every system hash format ($y$, $6$, bcrypt,
future) for free. A pure-Rust yescrypt crate exists but is early-stage and
unaudited, so it is deliberately not a dependency of this project — nothing
here reimplements the hash. The round-trip test in cargo test also goes
through the system crypt(3).
Everything else is pure Rust: std for I/O and exec, nix for the
privilege-drop syscalls, zeroize to wipe the plaintext buffer.
Needs a Rust toolchain and libxcrypt (libcrypt.so.1, present on any modern
Debian; the -lcrypt dev symlink from libcrypt-dev is used if available, and
build.rs falls back to the versioned SONAME otherwise).
cargo build --release # -> target/release/checkpasswordcargo test # parser, constant-time compare, live yescrypt round-trip
sh tests/exit_codes.sh # binary exit codes (needs the release build)cargo test includes yescrypt_roundtrip_via_libxcrypt, which produces a real
$y$ hash through the linked crypt(3) and asserts the right password verifies
and the wrong one does not.
Live positive check against your own account (needs your real password, and
root — like qmail/dovecot, it reads /etc/shadow, which is unreadable to
normal users, so without sudo it always exits 1):
printf '%s\0YOURPASSWORD\0Y0\0' "$(id -un)" | \
sudo sh -c './target/release/checkpassword /bin/true 3<&0'; echo $? # 0 = ok(The 3<&0 dup runs inside sudo on purpose — sudo's default closefrom
would otherwise close fd 3 before exec, yielding a spurious exit 111.)
The program is safe Rust except for two small, isolated FFI wrappers. unsafe
here does not mean "wrong" — it means the compiler cannot prove the contract, so
it is upheld by hand and documented.
The checkpassword protocol delivers credentials on file descriptor 3, not
stdin (the parent — qmail-pop3d, dovecot — opens a pipe on fd 3 before exec).
fd 3 is a raw descriptor number, and Rust's std offers no way to read a
descriptor by number, only File/Stdin types. from_raw_fd(3) wraps that
integer in a File so we can call .read().
It is unsafe because the compiler cannot verify that fd 3 is open (a bad
fd just yields a read error → exit 111, matching the C) nor that we own it
(File closes it on drop; a double-close would be a bug). In this program no
one else touches fd 3, and closing it at the end mirrors the C's close(3), so
the contract holds.
These are libc C functions, and Rust marks every FFI call unsafe because it
cannot check foreign code. The contracts upheld manually:
- Raw pointers —
namemust be a valid NUL-terminated string; we pass a pointer intobuf, NUL-terminated by the field delimiter. - Output buffer — the
(buf, len)pair we hand to C must be consistent, or C writes out of bounds; they are passed together and correct, and grown onERANGE. mem::zeroed()structs — thepasswd/spwdoutputs are zero-initialized for C to fill; valid because all-zero (NULL pointers) is a legal state for these structs.- Borrowed pointers — on return, fields like
pw_dirpoint into the scratch buffer, so the wrappers copy everything into ownedCString/Vecbefore that buffer is dropped, avoiding dangling pointers.
Both contracts are confined to the getpwnam/getspnam helpers in
src/main.rs; the rest of the program is safe.
Why not avoid it? Parsing /etc/passwd//etc/shadow by hand would be 100%
safe Rust but would silently drop NSS backends (LDAP, sss). The unsafe
buys real system integration — the right trade-off for an auth tool.
Ported 1:1 from the C fork (see its CHANGELOG):
- crypt() NULL check — locked accounts (
!,*,x) make libxcrypt returnNULL; handled as bad credentials instead of crashing. - Timing equalization — unknown users and hash-less accounts run a dummy
crypt()at the Debian default cost, so valid logins can't be enumerated by timing. - Plaintext wiping — the fd-3 buffer lives in
Zeroizingand is wiped after use, including on failure paths. - The C fork's manual
errnoclearing and allocator overflow guards are obviated bygetpwnam_r's explicit result and Rust's checkedVecgrowth.
Released under 0BSD. The underlying C program
checkpassword-0.90 by D. J. Bernstein is in the public domain (dedicated
2018-07-29, https://cr.yp.to/distributors.html).