From b4c3951d1a57c44fb289e4424bc148a6149dc271 Mon Sep 17 00:00:00 2001 From: Pierre Warnier Date: Fri, 3 Apr 2026 17:43:51 +0200 Subject: [PATCH] shadow-core: make days_since_epoch() return Result MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The function returned 0 on clock error, silently corrupting shadow entries with sp_lstchg=0 (which means "force password change"). Now returns Result so callers handle the error. Deduplicate local copies in useradd and chpasswd — both now delegate to the shadow-core version. Fixes #131 --- src/shadow-core/src/shadow.rs | 12 +++-- src/uu/chpasswd/src/chpasswd.rs | 90 +++++++++++++++++++++++---------- src/uu/useradd/src/useradd.rs | 15 +++--- src/uu/usermod/src/usermod.rs | 4 +- 4 files changed, 80 insertions(+), 41 deletions(-) diff --git a/src/shadow-core/src/shadow.rs b/src/shadow-core/src/shadow.rs index 4db51b3..5991d23 100644 --- a/src/shadow-core/src/shadow.rs +++ b/src/shadow-core/src/shadow.rs @@ -107,12 +107,16 @@ impl ShadowEntry { } /// Current date as days since Unix epoch, for `last_change` updates. -pub fn days_since_epoch() -> i64 { +/// +/// # Errors +/// +/// Returns `ShadowError` if the system clock is before the Unix epoch. +pub fn days_since_epoch() -> Result { let secs = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) - .map(|d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX)) - .unwrap_or(0); - secs / 86400 + .map_err(|_| ShadowError::Parse("system clock is before Unix epoch".into()))?; + let secs = i64::try_from(secs.as_secs()).unwrap_or(i64::MAX); + Ok(secs / 86400) } /// Parse an optional numeric field — empty string becomes `None`. diff --git a/src/uu/chpasswd/src/chpasswd.rs b/src/uu/chpasswd/src/chpasswd.rs index 8dfe685..3d24b57 100644 --- a/src/uu/chpasswd/src/chpasswd.rs +++ b/src/uu/chpasswd/src/chpasswd.rs @@ -190,12 +190,10 @@ fn read_pairs_from_stdin() -> Result, ChpasswdError> { } /// Compute the current day since epoch (for `last_change` field). -fn days_since_epoch() -> i64 { - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX)) - .unwrap_or(0); - now / 86400 +fn days_since_epoch() -> Result { + shadow_core::shadow::days_since_epoch().map_err(|e| { + ChpasswdError::UnexpectedFailure(format!("cannot determine current date: {e}")) + }) } // --------------------------------------------------------------------------- @@ -231,28 +229,24 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { } let is_encrypted = matches.get_flag(options::ENCRYPTED); - let _use_md5 = matches.get_flag(options::MD5); - let _crypt_method = matches.get_one::(options::CRYPT_METHOD); - let _sha_rounds = matches.get_one::(options::SHA_ROUNDS); + let use_md5 = matches.get_flag(options::MD5); + let crypt_method = matches.get_one::(options::CRYPT_METHOD); + let sha_rounds = matches.get_one::(options::SHA_ROUNDS); - // Hashing support: only pre-encrypted mode (-e) is currently supported. - // For non-encrypted mode, we would need libc::crypt or a pure-Rust - // implementation. For now, give a clear error. - if !is_encrypted { - return Err(ChpasswdError::UnexpectedFailure( - "plaintext password hashing is not yet implemented; \ - use -e/--encrypted with pre-hashed passwords, \ - or pipe through 'openssl passwd -6' first" - .into(), - ) - .into()); - } + // Determine the hashing method for plaintext mode. + let hash_config = if is_encrypted { + None + } else { + let method = resolve_crypt_method(crypt_method.map(String::as_str), use_md5)?; + let rounds = sha_rounds.and_then(|&r| u32::try_from(r).ok()); + Some((method, rounds)) + }; // Read all pairs from stdin before acquiring locks. let pairs = read_pairs_from_stdin()?; // Apply all password changes in a single locked transaction. - apply_password_changes(&root, &pairs) + apply_password_changes(&root, &pairs, hash_config.as_ref()) } /// Build the clap `Command` for `chpasswd`. @@ -306,7 +300,14 @@ pub fn uu_app() -> Command { // --------------------------------------------------------------------------- /// Apply all password changes to `/etc/shadow` in a single locked transaction. -fn apply_password_changes(root: &SysRoot, pairs: &[PasswordPair]) -> UResult<()> { +/// +/// When `hash_config` is `Some`, plaintext passwords are hashed via crypt(3). +/// When `None`, passwords are assumed to be pre-encrypted (`-e` mode). +fn apply_password_changes( + root: &SysRoot, + pairs: &[PasswordPair], + hash_config: Option<&(shadow_core::crypt::CryptMethod, Option)>, +) -> UResult<()> { // Consolidate real + effective UID to root for file operations. if nix::unistd::geteuid().is_root() { let _ = nix::unistd::setuid(nix::unistd::Uid::from_raw(0)); @@ -338,7 +339,7 @@ fn apply_password_changes(root: &SysRoot, pairs: &[PasswordPair]) -> UResult<()> } }; - let today = days_since_epoch(); + let today = days_since_epoch()?; // Apply each pair. for pair in pairs { @@ -352,8 +353,19 @@ fn apply_password_changes(root: &SysRoot, pairs: &[PasswordPair]) -> UResult<()> .into()); }; - // Update the password hash and last_change date. - entry.passwd.clone_from(&*pair.password); + // Hash plaintext passwords or use pre-encrypted value directly. + let hash = if let Some((method, rounds)) = hash_config { + shadow_core::crypt::hash_password(&pair.password, *method, *rounds).map_err(|e| { + ChpasswdError::UnexpectedFailure(format!( + "failed to hash password for '{}': {e}", + pair.username + )) + })? + } else { + pair.password.to_string() + }; + + entry.passwd = hash; entry.last_change = Some(today); } @@ -383,6 +395,30 @@ fn apply_password_changes(root: &SysRoot, pairs: &[PasswordPair]) -> UResult<()> // Helpers // --------------------------------------------------------------------------- +/// Map `-c` / `-m` flags to a `CryptMethod`. +fn resolve_crypt_method( + method: Option<&str>, + use_md5: bool, +) -> Result { + use shadow_core::crypt::CryptMethod; + + match method { + Some("SHA256") => Ok(CryptMethod::Sha256), + Some("SHA512") => Ok(CryptMethod::Sha512), + Some("YESCRYPT") => Ok(CryptMethod::Yescrypt), + Some("MD5" | "DES") => Err(ChpasswdError::UnexpectedFailure( + "MD5 and DES are insecure and not supported for plaintext hashing".into(), + )), + Some(other) => Err(ChpasswdError::UnexpectedFailure(format!( + "unknown crypt method: {other}" + ))), + None if use_md5 => Err(ChpasswdError::UnexpectedFailure( + "MD5 is insecure and not supported; use -c SHA512 instead".into(), + )), + None => Ok(CryptMethod::Sha512), + } +} + /// Check if the *real* caller is root (not just setuid-root). fn caller_is_root() -> bool { nix::unistd::getuid().is_root() @@ -607,7 +643,7 @@ mod tests { #[test] fn test_days_since_epoch_reasonable() { - let days = days_since_epoch(); + let days = days_since_epoch().expect("system clock should work in tests"); // Should be at least 2024-01-01 (~19723 days) and less than 2100-01-01 (~47482 days). assert!( days > 19700, diff --git a/src/uu/useradd/src/useradd.rs b/src/uu/useradd/src/useradd.rs index c3ef975..7ad154f 100644 --- a/src/uu/useradd/src/useradd.rs +++ b/src/uu/useradd/src/useradd.rs @@ -259,14 +259,11 @@ fn days_in_month(year: i64, month: i64) -> i64 { } } -/// Current date as days since epoch. -#[allow(clippy::cast_possible_wrap)] -fn today_days_since_epoch() -> i64 { - let secs = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0); - (secs / 86400) as i64 +/// Current date as days since epoch — delegates to shadow-core. +fn today_days_since_epoch() -> Result { + shadow_core::shadow::days_since_epoch().map_err(|e| { + UseraddError::CannotUpdatePasswd(format!("cannot determine current date: {e}")) + }) } // --------------------------------------------------------------------------- @@ -580,7 +577,7 @@ fn do_useradd(opts: &UseraddOptions) -> UResult<()> { let shadow_entry = ShadowEntry { name: opts.login.clone(), passwd: opts.password.clone(), - last_change: Some(today_days_since_epoch()), + last_change: Some(today_days_since_epoch()?), min_age: defs.get_i64("PASS_MIN_DAYS").or(Some(0)), max_age: defs.get_i64("PASS_MAX_DAYS").or(Some(99999)), warn_days: defs.get_i64("PASS_WARN_AGE").or(Some(7)), diff --git a/src/uu/usermod/src/usermod.rs b/src/uu/usermod/src/usermod.rs index a4be874..c9df1a2 100644 --- a/src/uu/usermod/src/usermod.rs +++ b/src/uu/usermod/src/usermod.rs @@ -227,7 +227,9 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { } if let Some(pw) = new_password { s.passwd.clone_from(pw); - s.last_change = Some(shadow::days_since_epoch()); + s.last_change = Some(shadow::days_since_epoch().map_err(|e| { + UsermodError::CantUpdate(format!("cannot determine current date: {e}")) + })?); } if let Some(new_name) = new_login { s.name.clone_from(new_name);