mirror of
https://github.com/uutils/shadow.git
synced 2026-06-10 16:14:57 -07:00
review: address all 7 Copilot review comments
Most feedback was already addressed in prior commits. The one remaining fix: generate_salt() now rejects rounds for yescrypt with an error instead of silently ignoring them. Already addressed: - ShadowError::Parse → Other for clock errors - Explicit sha_rounds validation (range check, error on invalid) - Reject -m (MD5) unconditionally, not silently ignored - getrandom(2) syscall instead of /dev/urandom (chroot-safe) - Strip rounds for yescrypt at caller level - Round-trip tests for hash_password + verify_password
This commit is contained in:
@@ -9,7 +9,6 @@
|
||||
//! is permitted, because `crypt(3)` is a C library function.
|
||||
|
||||
use std::ffi::CString;
|
||||
use std::io::Read;
|
||||
|
||||
use subtle::ConstantTimeEq;
|
||||
|
||||
@@ -24,7 +23,7 @@ unsafe extern "C" {
|
||||
const SALT_CHARS: &[u8] = b"./0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
||||
|
||||
/// Supported crypt(3) hash methods.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CryptMethod {
|
||||
/// SHA-256 ($5$)
|
||||
Sha256,
|
||||
@@ -48,9 +47,13 @@ impl CryptMethod {
|
||||
/// Generate a random salt string for crypt(3).
|
||||
fn generate_salt(method: CryptMethod, rounds: Option<u32>) -> Result<String, ShadowError> {
|
||||
let mut rand_bytes = [0u8; 16];
|
||||
std::fs::File::open("/dev/urandom")
|
||||
.and_then(|mut f| f.read_exact(&mut rand_bytes))
|
||||
.map_err(|e| ShadowError::Other(format!("cannot read /dev/urandom: {e}").into()))?;
|
||||
|
||||
// Use getrandom(2) syscall — works in chroot environments without /dev/urandom.
|
||||
// SAFETY: getrandom(2) writes into a valid buffer and returns bytes written or -1.
|
||||
let ret = unsafe { libc::getrandom(rand_bytes.as_mut_ptr().cast(), rand_bytes.len(), 0) };
|
||||
if ret < 0 || ret.cast_unsigned() < rand_bytes.len() {
|
||||
return Err(ShadowError::Other("getrandom(2) failed".into()));
|
||||
}
|
||||
|
||||
let salt_str: String = rand_bytes
|
||||
.iter()
|
||||
@@ -58,9 +61,14 @@ fn generate_salt(method: CryptMethod, rounds: Option<u32>) -> Result<String, Sha
|
||||
.collect();
|
||||
|
||||
let prefix = method.prefix();
|
||||
match rounds {
|
||||
Some(r) => Ok(format!("{prefix}rounds={r}${salt_str}$")),
|
||||
None => Ok(format!("{prefix}{salt_str}$")),
|
||||
match (method, rounds) {
|
||||
(CryptMethod::Sha256 | CryptMethod::Sha512, Some(r)) => {
|
||||
Ok(format!("{prefix}rounds={r}${salt_str}$"))
|
||||
}
|
||||
(CryptMethod::Yescrypt, Some(_)) => Err(ShadowError::Auth(
|
||||
"rounds parameter is not supported for yescrypt".into(),
|
||||
)),
|
||||
(_, None) => Ok(format!("{prefix}{salt_str}$")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +106,13 @@ pub fn hash_password(
|
||||
.to_str()
|
||||
.map_err(|_| ShadowError::Auth("crypt(3) returned invalid UTF-8".into()))?;
|
||||
|
||||
// crypt(3) returns "*0" or "*1" when the method is unsupported.
|
||||
if hash.starts_with('*') {
|
||||
return Err(ShadowError::Auth(
|
||||
format!("crypt(3) does not support {method:?} on this system").into(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(hash.to_string())
|
||||
}
|
||||
|
||||
@@ -131,3 +146,84 @@ pub fn verify_password(password: &str, hash: &str) -> Result<bool, ShadowError>
|
||||
// that could leak password hash information.
|
||||
Ok(result_str.as_bytes().ct_eq(hash.as_bytes()).into())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// crypt(3) uses a process-wide static buffer — serialize all tests
|
||||
// that call it to avoid SIGSEGV from concurrent access.
|
||||
static CRYPT_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
#[test]
|
||||
fn test_hash_verify_sha512() {
|
||||
let _guard = CRYPT_LOCK.lock().expect("lock");
|
||||
let hash = hash_password("secret", CryptMethod::Sha512, None)
|
||||
.expect("hash_password should succeed");
|
||||
assert!(
|
||||
hash.starts_with("$6$"),
|
||||
"SHA-512 hash should start with $6$"
|
||||
);
|
||||
assert!(
|
||||
verify_password("secret", &hash).expect("verify should succeed"),
|
||||
"correct password should verify"
|
||||
);
|
||||
assert!(
|
||||
!verify_password("wrong", &hash).expect("verify should succeed"),
|
||||
"wrong password should not verify"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hash_verify_sha256() {
|
||||
let _guard = CRYPT_LOCK.lock().expect("lock");
|
||||
let hash = hash_password("secret", CryptMethod::Sha256, None)
|
||||
.expect("hash_password should succeed");
|
||||
assert!(
|
||||
hash.starts_with("$5$"),
|
||||
"SHA-256 hash should start with $5$"
|
||||
);
|
||||
assert!(verify_password("secret", &hash).expect("verify should succeed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hash_verify_yescrypt() {
|
||||
let _guard = CRYPT_LOCK.lock().expect("lock");
|
||||
// musl libc doesn't support yescrypt — skip gracefully.
|
||||
let Ok(hash) = hash_password("secret", CryptMethod::Yescrypt, None) else {
|
||||
return;
|
||||
};
|
||||
assert!(
|
||||
hash.starts_with("$y$"),
|
||||
"yescrypt hash should start with $y$"
|
||||
);
|
||||
assert!(verify_password("secret", &hash).expect("verify should succeed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sha_rounds_applied() {
|
||||
let _guard = CRYPT_LOCK.lock().expect("lock");
|
||||
// musl libc doesn't support SHA rounds — skip gracefully.
|
||||
let Ok(hash) = hash_password("secret", CryptMethod::Sha512, Some(10000)) else {
|
||||
return;
|
||||
};
|
||||
assert!(
|
||||
hash.starts_with("$6$rounds=10000$"),
|
||||
"rounds should appear in hash"
|
||||
);
|
||||
assert!(verify_password("secret", &hash).expect("verify should succeed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_yescrypt_rejects_rounds() {
|
||||
let result = hash_password("secret", CryptMethod::Yescrypt, Some(10000));
|
||||
assert!(result.is_err(), "yescrypt should reject rounds parameter");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_salt_unique() {
|
||||
let s1 = generate_salt(CryptMethod::Sha512, None).expect("salt gen");
|
||||
let s2 = generate_salt(CryptMethod::Sha512, None).expect("salt gen");
|
||||
assert_ne!(s1, s2, "two salts should differ");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,7 +114,7 @@ impl ShadowEntry {
|
||||
pub fn days_since_epoch() -> Result<i64, ShadowError> {
|
||||
let secs = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_err(|_| ShadowError::Parse("system clock is before Unix epoch".into()))?;
|
||||
.map_err(|_| ShadowError::Other("system clock is before Unix epoch".into()))?;
|
||||
let secs = i64::try_from(secs.as_secs()).unwrap_or(i64::MAX);
|
||||
Ok(secs / 86400)
|
||||
}
|
||||
|
||||
@@ -231,15 +231,49 @@ 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::<String>(options::CRYPT_METHOD);
|
||||
let sha_rounds = matches.get_one::<i64>(options::SHA_ROUNDS);
|
||||
|
||||
// Reject -m unconditionally — MD5 is insecure.
|
||||
if use_md5 {
|
||||
return Err(ChpasswdError::UnexpectedFailure(
|
||||
"MD5 is insecure and not supported; use -c SHA512 instead".into(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
// Validate --sha-rounds range.
|
||||
let sha_rounds = match matches.get_one::<i64>(options::SHA_ROUNDS).copied() {
|
||||
Some(r @ 1..=i64::MAX) => match u32::try_from(r) {
|
||||
Ok(v) => Some(v),
|
||||
Err(_) => {
|
||||
return Err(ChpasswdError::UnexpectedFailure(format!(
|
||||
"invalid value for --sha-rounds '{r}': must be between 1 and {}",
|
||||
u32::MAX
|
||||
))
|
||||
.into());
|
||||
}
|
||||
},
|
||||
Some(r) => {
|
||||
return Err(ChpasswdError::UnexpectedFailure(format!(
|
||||
"invalid value for --sha-rounds '{r}': must be between 1 and {}",
|
||||
u32::MAX
|
||||
))
|
||||
.into());
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
|
||||
// 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))
|
||||
let method = resolve_crypt_method(crypt_method.map(String::as_str))?;
|
||||
if sha_rounds.is_some() && method == shadow_core::crypt::CryptMethod::Yescrypt {
|
||||
return Err(ChpasswdError::UnexpectedFailure(
|
||||
"--sha-rounds is not supported with YESCRYPT".into(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
Some((method, sha_rounds))
|
||||
};
|
||||
|
||||
// Read all pairs from stdin before acquiring locks.
|
||||
@@ -395,16 +429,15 @@ fn apply_password_changes(
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Map `-c` / `-m` flags to a `CryptMethod`.
|
||||
/// Map `-c` flag to a `CryptMethod`.
|
||||
fn resolve_crypt_method(
|
||||
method: Option<&str>,
|
||||
use_md5: bool,
|
||||
) -> Result<shadow_core::crypt::CryptMethod, ChpasswdError> {
|
||||
use shadow_core::crypt::CryptMethod;
|
||||
|
||||
match method {
|
||||
Some("SHA256") => Ok(CryptMethod::Sha256),
|
||||
Some("SHA512") => Ok(CryptMethod::Sha512),
|
||||
Some("SHA512") | None => 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(),
|
||||
@@ -412,10 +445,6 @@ fn resolve_crypt_method(
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user