Skip to content
Merged
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
2 changes: 1 addition & 1 deletion docs/architecture/server-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -488,7 +488,7 @@ The generated hash includes:
- Time cost: 2 iterations
- Parallelism: 1

Note: bcrypt hashes are also supported for backward compatibility with existing configurations.
Note: bcrypt hashes are also supported for backward compatibility with existing configurations. SHA-crypt hashes (`$5$`, `$6$`, such as the output of `openssl passwd -6`) and values copied from `/etc/shadow` are not supported. The server logs a warning for each such user when it loads the configuration, and that user cannot log in with a password.

### Validate Configuration

Expand Down
88 changes: 88 additions & 0 deletions src/server/auth/password.rs
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,17 @@ impl PasswordVerifier {
users.insert(user.name.clone(), user.clone());
}

// Flag users that can never log in. The auth path already fails closed,
// this only surfaces the misconfiguration at load time. Never log the hash.
for (name, user) in &users {
if !is_supported_hash_format(&user.password_hash) {
tracing::warn!(
user = %name,
"Unsupported password hash format; supported formats are Argon2id (bssh-server hash-password) and bcrypt ($2a$/$2b$/$2y$). This user cannot log in with a password."
);
}
}

let user_count = users.len();
*self.users.write().await = users;

Expand Down Expand Up @@ -485,6 +496,17 @@ pub fn verify_password_hash(password: &str, hash: &str) -> Result<bool> {
}
}

/// Check whether a stored hash is in a format the verifier can handle.
///
/// Mirrors the formats accepted by `verify_internal` (Argon2 and bcrypt) without
/// changing its dispatch. Used only to warn at load time.
fn is_supported_hash_format(hash: &str) -> bool {
if hash.starts_with("$argon2") {
return PasswordHash::new(hash).is_ok();
}
hash.starts_with("$2a$") || hash.starts_with("$2b$") || hash.starts_with("$2y$")
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -788,4 +810,70 @@ mod tests {
assert!(verify_password_hash(&password, &hash).unwrap());
assert!(!verify_password_hash(&"a".repeat(999), &hash).unwrap());
}

#[test]
fn test_is_supported_hash_format() {
assert!(is_supported_hash_format(&hash_password("pw").unwrap()));
assert!(is_supported_hash_format(&bcrypt::hash("pw", 4).unwrap()));
for bad in [
"$6$rounds=5000$salt$hash",
"$5$salt$hash",
"*",
"!",
"",
"$argon2id$v=19$",
] {
assert!(!is_supported_hash_format(bad), "should reject {bad:?}");
}
}

fn user_with_hash(name: &str, hash: String) -> UserDefinition {
UserDefinition {
name: name.to_string(),
password_hash: hash,
shell: None,
home: None,
env: HashMap::new(),
}
}

#[tokio::test]
async fn test_verifier_builds_with_unsupported_hashes() {
let users = vec![
user_with_hash("locked", "*".to_string()),
user_with_hash("shacrypt", "$6$rounds=5000$saltsalt$abc".to_string()),
];
let verifier = PasswordVerifier::new(PasswordAuthConfig::with_users(users)).await;
assert!(verifier.is_ok());
}

#[tokio::test]
async fn test_unsupported_hashes_reject_all_password_lengths() {
let users = vec![
user_with_hash("star", "*".to_string()),
user_with_hash("bang", "!".to_string()),
user_with_hash("empty", String::new()),
user_with_hash("shacrypt", "$6$rounds=5000$saltsalt$abc".to_string()),
user_with_hash("argonuser", hash_password("argon_pw").unwrap()),
user_with_hash("bcryptuser", bcrypt::hash("bcrypt_pw", 4).unwrap()),
];
let verifier = PasswordVerifier::new(PasswordAuthConfig::with_users(users))
.await
.unwrap();

for user in ["star", "bang", "empty", "shacrypt"] {
for len in [0usize, 1, 72, 256, 257, 1024] {
let pw = "a".repeat(len);
assert!(
!verifier.verify(user, &pw).await.unwrap(),
"user {user} must not authenticate with a password of length {len}"
);
}
}

assert!(verifier.verify("argonuser", "argon_pw").await.unwrap());
assert!(!verifier.verify("argonuser", "wrong").await.unwrap());
assert!(verifier.verify("bcryptuser", "bcrypt_pw").await.unwrap());
assert!(!verifier.verify("bcryptuser", "wrong").await.unwrap());
}
}
10 changes: 7 additions & 3 deletions src/server/config/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -236,9 +236,13 @@ pub struct UserDefinition {
/// Username.
pub name: String,

/// Password hash (bcrypt or similar).
/// Password hash.
///
/// Generate with: `openssl passwd -6`
/// Argon2id is recommended; generate it with `bssh-server hash-password`.
/// bcrypt (`$2a$`, `$2b$`, `$2y$`) is accepted for compatibility.
/// SHA-crypt hashes (`openssl passwd -5` / `-6`, `$5$` / `$6$`) and values
/// copied from `/etc/shadow` are not supported and are rejected, so such a
/// user cannot log in with a password.
pub password_hash: String,

/// Override shell for this user.
Expand Down Expand Up @@ -847,7 +851,7 @@ auth:
password:
users:
- name: testuser
password_hash: "$6$rounds=656000$..."
password_hash: "$argon2id$v=19$m=19456,t=2,p=1$c2FsdHNhbHQ$aGFzaA"
shell: /bin/bash

shell:
Expand Down
Loading