Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
5 changes: 4 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,19 @@ edition = "2024"
include = ["src/**/*", "LICENSE", "README.md"]

[features]
default = ["std", "zeroize"]
default = ["std", "zeroize", "password-hash"]

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it probably shouldn't be part of the default feature

std = ["getrandom/std", "base64/std"]
alloc = ["base64/alloc", "getrandom"]
password-hash = ["dep:mcf", "dep:password-hash", "alloc"]

[dependencies]
blowfish = { version = "0.9", features = ["bcrypt"] }
getrandom = { version = "0.4", default-features = false, optional = true }
base64 = { version = "0.22", default-features = false }
zeroize = { version = "1.5.4", optional = true }
subtle = { version = "2.4.1", default-features = false }
mcf = { version = "0.6.0", optional = true }
password-hash = { version = "0.6.0", default-features = false, optional = true }

[dev-dependencies]
# no default features avoid pulling in log
Expand Down
30 changes: 30 additions & 0 deletions src/bcrypt.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
use blowfish::Blowfish;

#[cfg(feature = "password-hash")]
use crate::BcryptError;

fn setup(cost: u32, salt: &[u8], key: &[u8]) -> Blowfish {
assert!(cost < 32);
let mut state = Blowfish::bc_init_state();
Expand Down Expand Up @@ -41,6 +44,33 @@ pub fn bcrypt(cost: u32, salt: [u8; 16], password: &[u8]) -> [u8; 24] {
output
}

#[cfg(feature = "password-hash")]
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
/// bcrypt context.
pub struct Bcrypt {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can that be used internally? a bit weird to have that just for the password-hash feature and this mean we duplicate the cost check

pub(crate) cost: u32,
}

#[cfg(feature = "password-hash")]
impl Bcrypt {
/// Creates a new [`Bcrypt`] with the given `cost`.
pub const fn new(cost: u32) -> Result<Self, BcryptError> {
match cost {
cost @ 4..=31 => Ok(Self { cost }),
cost => Err(BcryptError::CostNotAllowed(cost)),
}
}
}

#[cfg(feature = "password-hash")]
impl Default for Bcrypt {
fn default() -> Self {
Self {
cost: crate::DEFAULT_COST,
}
}
}

#[cfg(test)]
mod tests {
use super::bcrypt;
Expand Down
7 changes: 7 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,16 @@ use core::fmt;
#[cfg(any(feature = "alloc", feature = "std"))]
use {base64::Engine, core::convert::AsRef, core::str::FromStr};

#[cfg(feature = "password-hash")]
pub use password_hash;

mod bcrypt;
mod errors;
#[cfg(feature = "password-hash")]
pub mod mcf;

#[cfg(feature = "password-hash")]
pub use crate::bcrypt::Bcrypt;
pub use crate::bcrypt::bcrypt;
pub use crate::errors::{BcryptError, BcryptResult};

Expand Down
190 changes: 190 additions & 0 deletions src/mcf.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
//! Implementation of the [`password_hash`] traits for Modular Crypt Format
//! (MCF) password hash strings which begin with `$2b$` or any other alternative
//! prefix:
//!
//! <https://man.archlinux.org/man/crypt.5#bcrypt>

pub use mcf::{PasswordHash, PasswordHashRef};
use password_hash::{CustomizedPasswordHasher, Error, PasswordHasher, PasswordVerifier, Result};

use crate::{Bcrypt, Version};

impl CustomizedPasswordHasher<PasswordHash> for Bcrypt {
type Params = u32;

fn hash_password_customized(
&self,
password: &[u8],
salt: &[u8],
alg_id: Option<&str>,
version: Option<password_hash::Version>,
params: Self::Params,

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is the params the cost?

) -> Result<PasswordHash> {
let hash_version = match alg_id {
Some("2a") => Version::TwoA,
Some("2b") | None => Version::TwoB,
Some("2x") => Version::TwoX,
Some("2y") => Version::TwoY,
_ => return Err(Error::Algorithm),
};

if version.is_some() {
return Err(Error::Version);
}

let salt = salt.try_into().map_err(|_| Error::Internal)?;
let hash = crate::hash_with_salt(password, params, salt).map_err(|_| Error::Internal)?;

let mcf_hash = hash.format_for_version(hash_version);
let mcf_hash = PasswordHash::new(mcf_hash).unwrap();
Ok(mcf_hash)
}
}

impl PasswordHasher<PasswordHash> for Bcrypt {
fn hash_password_with_salt(&self, password: &[u8], salt: &[u8]) -> Result<PasswordHash> {
self.hash_password_customized(password, salt, None, None, self.cost)
}
}

impl PasswordVerifier<PasswordHash> for Bcrypt {
fn verify_password(&self, password: &[u8], hash: &PasswordHash) -> Result<()> {
self.verify_password(password, hash.as_password_hash_ref())
}
}

impl PasswordVerifier<PasswordHashRef> for Bcrypt {
fn verify_password(&self, password: &[u8], hash: &PasswordHashRef) -> Result<()> {
let is_valid = crate::verify(password, hash.as_str()).map_err(|_| Error::Internal)?;
if is_valid {
Ok(())
} else {
Err(Error::PasswordInvalid)
}
}
}

#[cfg(test)]
mod tests {
use super::{
Bcrypt, CustomizedPasswordHasher, Error, PasswordHash, PasswordHashRef, PasswordHasher,
PasswordVerifier,
};

#[test]
fn hash_password() {
// 2a
let actual_hash: PasswordHash = Bcrypt::default()
.hash_password_customized(b"hunter2", &[0; 16], Some("2a"), None, crate::DEFAULT_COST)
.unwrap();
let expected_hash =
PasswordHash::new("$2a$12$......................21jzCB1r6pN6rp5O2Ev0ejjTAboskKm")
.unwrap();
assert_eq!(expected_hash, actual_hash);
// 2b
let actual_hash: PasswordHash = Bcrypt::default()
.hash_password_with_salt(b"hunter2", &[0; 16])
.unwrap();
let expected_hash =
PasswordHash::new("$2b$12$......................21jzCB1r6pN6rp5O2Ev0ejjTAboskKm")
.unwrap();
assert_eq!(expected_hash, actual_hash);
// 2x
let actual_hash: PasswordHash = Bcrypt::default()
.hash_password_customized(b"hunter2", &[0; 16], Some("2x"), None, crate::DEFAULT_COST)
.unwrap();
let expected_hash =
PasswordHash::new("$2x$12$......................21jzCB1r6pN6rp5O2Ev0ejjTAboskKm")
.unwrap();
assert_eq!(expected_hash, actual_hash);
// 2y
let actual_hash: PasswordHash = Bcrypt::default()
.hash_password_customized(b"hunter2", &[0; 16], Some("2y"), None, crate::DEFAULT_COST)
.unwrap();
let expected_hash =
PasswordHash::new("$2y$12$......................21jzCB1r6pN6rp5O2Ev0ejjTAboskKm")
.unwrap();
assert_eq!(expected_hash, actual_hash);
}

#[test]
fn verify_password() {
// `can_verify_hash_generated_from_some_online_tool`
let hash =
PasswordHashRef::new("$2a$04$UuTkLRZZ6QofpDOlMz32MuuxEHA43WOemOYHPz6.SjsVsyO1tDU96")
.unwrap();
assert_eq!(Bcrypt::default().verify_password(b"password", hash), Ok(()));
// `can_verify_hash_generated_from_python`
let hash =
PasswordHashRef::new("$2b$04$EGdrhbKUv8Oc9vGiXX0HQOxSg445d458Muh7DAHskb6QbtCvdxcie")
.unwrap();
assert_eq!(
Bcrypt::default().verify_password(b"correctbatteryhorsestapler", hash),
Ok(())
);
// `can_verify_hash_generated_from_node`
let hash =
PasswordHashRef::new("$2a$04$n4Uy0eSnMfvnESYL.bLwuuj0U/ETSsoTpRT9GVk5bektyVVa5xnIi")
.unwrap();
assert_eq!(
Bcrypt::default().verify_password(b"correctbatteryhorsestapler", hash),
Ok(())
);
// `can_verify_hash_generated_from_go`
let binary_input = [
29, 225, 195, 167, 223, 236, 85, 195, 114, 227, 7, 0, 209, 239, 189, 24, 51, 105, 124,
168, 151, 75, 144, 64, 198, 197, 196, 4, 241, 97, 110, 135,
];
let hash =
PasswordHashRef::new("$2a$04$tjARW6ZON3PhrAIRW2LG/u9aDw5eFdstYLR8nFCNaOQmsH9XD23w.")
.unwrap();
assert_eq!(
Bcrypt::default().verify_password(&binary_input, hash),
Ok(())
);

// `invalid_hash_does_not_panic`
let binary_input = [
29, 225, 195, 167, 223, 236, 85, 195, 114, 227, 7, 0, 209, 239, 189, 24, 51, 105, 124,
168, 151, 75, 144, 64, 198, 197, 196, 4, 241, 97, 110, 135,
];
let hash = PasswordHashRef::new("$2a$04$tjARW6ZON3PhrAIRW2LG/u9a.").unwrap();
assert_eq!(
Bcrypt::default().verify_password(&binary_input, hash),
Err(Error::Internal)
);
// `a_wrong_password_is_false`
let hash =
PasswordHashRef::new("$2b$04$EGdrhbKUv8Oc9vGiXX0HQOxSg445d458Muh7DAHskb6QbtCvdxcie")
.unwrap();
assert_eq!(
Bcrypt::default().verify_password(b"wrong", hash),
Err(Error::PasswordInvalid)
);
// `errors_with_invalid_hash`
let hash =
PasswordHashRef::new("$2a$04$n4Uy0eSnMfvnESYL.bLwuuj0U/ETSsoTpRT9GVk$5bektyVVa5xnIi")
.unwrap();
assert_eq!(
Bcrypt::default().verify_password(b"correctbatteryhorsestapler", hash),
Err(Error::Internal)
);
// `errors_with_non_number_cost`
let hash =
PasswordHashRef::new("$2a$ab$n4Uy0eSnMfvnESYL.bLwuuj0U/ETSsoTpRT9GVk$5bektyVVa5xnIi")
.unwrap();
assert_eq!(
Bcrypt::default().verify_password(b"correctbatteryhorsestapler", hash),
Err(Error::Internal)
);
// `errors_with_a_hash_too_long`
let hash = PasswordHashRef::new(
"$2a$04$n4Uy0eSnMfvnESYL.bLwuuj0U/ETSsoTpRT9GVk$5bektyVVa5xnIerererereri",
)
.unwrap();
assert_eq!(
Bcrypt::default().verify_password(b"correctbatteryhorsestapler", hash),
Err(Error::Internal)
);
}
}
Loading