Skip to content

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

rust-checkpassword

License

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.

Interface

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)

Why it calls system crypt(3)

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.

Building

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/checkpassword

Testing

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

Notes on unsafe

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.

File::from_raw_fd(3) — reading credentials

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.

getpwnam_r / getspnam_r — NSS lookups

These are libc C functions, and Rust marks every FFI call unsafe because it cannot check foreign code. The contracts upheld manually:

  • Raw pointersname must be a valid NUL-terminated string; we pass a pointer into buf, 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 on ERANGE.
  • mem::zeroed() structs — the passwd/spwd outputs 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_dir point into the scratch buffer, so the wrappers copy everything into owned CString/Vec before 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.

Security hardening

Ported 1:1 from the C fork (see its CHANGELOG):

  • crypt() NULL check — locked accounts (!, *, x) make libxcrypt return NULL; 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 Zeroizing and is wiped after use, including on failure paths.
  • The C fork's manual errno clearing and allocator overflow guards are obviated by getpwnam_r's explicit result and Rust's checked Vec growth.

Dependencies

License

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

About

checkpassword ported to rust.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages