From b29b9d16a6abae0ba0499d11dddc259dee0ed4a8 Mon Sep 17 00:00:00 2001 From: Shun Sakai Date: Tue, 17 Mar 2026 21:24:51 +0900 Subject: [PATCH 1/3] feat: Add `password-hash` feature --- Cargo.toml | 5 +- src/bcrypt.rs | 30 ++++++++ src/lib.rs | 7 ++ src/mcf.rs | 190 ++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 231 insertions(+), 1 deletion(-) create mode 100644 src/mcf.rs diff --git a/Cargo.toml b/Cargo.toml index 853dca8..e80cf8c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,9 +12,10 @@ edition = "2024" include = ["src/**/*", "LICENSE", "README.md"] [features] -default = ["std", "zeroize"] +default = ["std", "zeroize", "password-hash"] std = ["getrandom/std", "base64/std"] alloc = ["base64/alloc", "getrandom"] +password-hash = ["dep:mcf", "dep:password-hash", "alloc"] [dependencies] blowfish = { version = "0.9", features = ["bcrypt"] } @@ -22,6 +23,8 @@ 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 diff --git a/src/bcrypt.rs b/src/bcrypt.rs index 9f0b16e..1bc3e15 100644 --- a/src/bcrypt.rs +++ b/src/bcrypt.rs @@ -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(); @@ -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 { + 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 { + 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; diff --git a/src/lib.rs b/src/lib.rs index 7edc627..54bfbd1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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}; diff --git a/src/mcf.rs b/src/mcf.rs new file mode 100644 index 0000000..35090a1 --- /dev/null +++ b/src/mcf.rs @@ -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: +//! +//! + +pub use mcf::{PasswordHash, PasswordHashRef}; +use password_hash::{CustomizedPasswordHasher, Error, PasswordHasher, PasswordVerifier, Result}; + +use crate::{Bcrypt, Version}; + +impl CustomizedPasswordHasher for Bcrypt { + type Params = u32; + + fn hash_password_customized( + &self, + password: &[u8], + salt: &[u8], + alg_id: Option<&str>, + version: Option, + params: Self::Params, + ) -> Result { + 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 for Bcrypt { + fn hash_password_with_salt(&self, password: &[u8], salt: &[u8]) -> Result { + self.hash_password_customized(password, salt, None, None, self.cost) + } +} + +impl PasswordVerifier for Bcrypt { + fn verify_password(&self, password: &[u8], hash: &PasswordHash) -> Result<()> { + self.verify_password(password, hash.as_password_hash_ref()) + } +} + +impl PasswordVerifier 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) + ); + } +} From 75d17e1aa01c585c9a963841e6f6c4eb80b251de Mon Sep 17 00:00:00 2001 From: Shun Sakai Date: Mon, 13 Apr 2026 14:38:16 +0900 Subject: [PATCH 2/3] feat!: Remove `password-hash` from default feature --- .github/workflows/ci.yml | 5 ++++- Cargo.toml | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index de8174f..470b620 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,6 +37,9 @@ jobs: - name: Run tests run: cargo test + - name: Run tests with all features + run: cargo test --all-features + - name: Run tests using no_std if: matrix.test_no_std == true run: cargo test --no-default-features --features alloc @@ -82,4 +85,4 @@ jobs: toolchain: 1.85.0 components: clippy - - run: cargo clippy -- -D warnings + - run: cargo clippy --all-features -- -D warnings diff --git a/Cargo.toml b/Cargo.toml index e80cf8c..df12530 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ edition = "2024" include = ["src/**/*", "LICENSE", "README.md"] [features] -default = ["std", "zeroize", "password-hash"] +default = ["std", "zeroize"] std = ["getrandom/std", "base64/std"] alloc = ["base64/alloc", "getrandom"] password-hash = ["dep:mcf", "dep:password-hash", "alloc"] From 0c505b036ba43e773a5e9466dca2b4eee8b70433 Mon Sep 17 00:00:00 2001 From: Shun Sakai Date: Mon, 13 Apr 2026 14:53:34 +0900 Subject: [PATCH 3/3] feat!: Change `Bcrypt` to unit struct --- src/bcrypt.rs | 29 ++--------------------------- src/mcf.rs | 35 ++++++++++++++++------------------- 2 files changed, 18 insertions(+), 46 deletions(-) diff --git a/src/bcrypt.rs b/src/bcrypt.rs index 1bc3e15..b19db4c 100644 --- a/src/bcrypt.rs +++ b/src/bcrypt.rs @@ -1,8 +1,5 @@ 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(); @@ -45,31 +42,9 @@ pub fn bcrypt(cost: u32, salt: [u8; 16], password: &[u8]) -> [u8; 24] { } #[cfg(feature = "password-hash")] -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Copy, Debug)] /// bcrypt context. -pub struct Bcrypt { - 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 { - 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, - } - } -} +pub struct Bcrypt; #[cfg(test)] mod tests { diff --git a/src/mcf.rs b/src/mcf.rs index 35090a1..50a6a64 100644 --- a/src/mcf.rs +++ b/src/mcf.rs @@ -18,7 +18,7 @@ impl CustomizedPasswordHasher for Bcrypt { salt: &[u8], alg_id: Option<&str>, version: Option, - params: Self::Params, + cost: Self::Params, ) -> Result { let hash_version = match alg_id { Some("2a") => Version::TwoA, @@ -33,7 +33,7 @@ impl CustomizedPasswordHasher for Bcrypt { } let salt = salt.try_into().map_err(|_| Error::Internal)?; - let hash = crate::hash_with_salt(password, params, salt).map_err(|_| Error::Internal)?; + let hash = crate::hash_with_salt(password, cost, salt).map_err(|_| Error::Internal)?; let mcf_hash = hash.format_for_version(hash_version); let mcf_hash = PasswordHash::new(mcf_hash).unwrap(); @@ -43,7 +43,7 @@ impl CustomizedPasswordHasher for Bcrypt { impl PasswordHasher for Bcrypt { fn hash_password_with_salt(&self, password: &[u8], salt: &[u8]) -> Result { - self.hash_password_customized(password, salt, None, None, self.cost) + self.hash_password_customized(password, salt, None, None, crate::DEFAULT_COST) } } @@ -74,7 +74,7 @@ mod tests { #[test] fn hash_password() { // 2a - let actual_hash: PasswordHash = Bcrypt::default() + let actual_hash: PasswordHash = Bcrypt .hash_password_customized(b"hunter2", &[0; 16], Some("2a"), None, crate::DEFAULT_COST) .unwrap(); let expected_hash = @@ -82,7 +82,7 @@ mod tests { .unwrap(); assert_eq!(expected_hash, actual_hash); // 2b - let actual_hash: PasswordHash = Bcrypt::default() + let actual_hash: PasswordHash = Bcrypt .hash_password_with_salt(b"hunter2", &[0; 16]) .unwrap(); let expected_hash = @@ -90,7 +90,7 @@ mod tests { .unwrap(); assert_eq!(expected_hash, actual_hash); // 2x - let actual_hash: PasswordHash = Bcrypt::default() + let actual_hash: PasswordHash = Bcrypt .hash_password_customized(b"hunter2", &[0; 16], Some("2x"), None, crate::DEFAULT_COST) .unwrap(); let expected_hash = @@ -98,7 +98,7 @@ mod tests { .unwrap(); assert_eq!(expected_hash, actual_hash); // 2y - let actual_hash: PasswordHash = Bcrypt::default() + let actual_hash: PasswordHash = Bcrypt .hash_password_customized(b"hunter2", &[0; 16], Some("2y"), None, crate::DEFAULT_COST) .unwrap(); let expected_hash = @@ -113,13 +113,13 @@ mod tests { let hash = PasswordHashRef::new("$2a$04$UuTkLRZZ6QofpDOlMz32MuuxEHA43WOemOYHPz6.SjsVsyO1tDU96") .unwrap(); - assert_eq!(Bcrypt::default().verify_password(b"password", hash), Ok(())); + assert_eq!(Bcrypt.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), + Bcrypt.verify_password(b"correctbatteryhorsestapler", hash), Ok(()) ); // `can_verify_hash_generated_from_node` @@ -127,7 +127,7 @@ mod tests { PasswordHashRef::new("$2a$04$n4Uy0eSnMfvnESYL.bLwuuj0U/ETSsoTpRT9GVk5bektyVVa5xnIi") .unwrap(); assert_eq!( - Bcrypt::default().verify_password(b"correctbatteryhorsestapler", hash), + Bcrypt.verify_password(b"correctbatteryhorsestapler", hash), Ok(()) ); // `can_verify_hash_generated_from_go` @@ -138,10 +138,7 @@ mod tests { let hash = PasswordHashRef::new("$2a$04$tjARW6ZON3PhrAIRW2LG/u9aDw5eFdstYLR8nFCNaOQmsH9XD23w.") .unwrap(); - assert_eq!( - Bcrypt::default().verify_password(&binary_input, hash), - Ok(()) - ); + assert_eq!(Bcrypt.verify_password(&binary_input, hash), Ok(())); // `invalid_hash_does_not_panic` let binary_input = [ @@ -150,7 +147,7 @@ mod tests { ]; let hash = PasswordHashRef::new("$2a$04$tjARW6ZON3PhrAIRW2LG/u9a.").unwrap(); assert_eq!( - Bcrypt::default().verify_password(&binary_input, hash), + Bcrypt.verify_password(&binary_input, hash), Err(Error::Internal) ); // `a_wrong_password_is_false` @@ -158,7 +155,7 @@ mod tests { PasswordHashRef::new("$2b$04$EGdrhbKUv8Oc9vGiXX0HQOxSg445d458Muh7DAHskb6QbtCvdxcie") .unwrap(); assert_eq!( - Bcrypt::default().verify_password(b"wrong", hash), + Bcrypt.verify_password(b"wrong", hash), Err(Error::PasswordInvalid) ); // `errors_with_invalid_hash` @@ -166,7 +163,7 @@ mod tests { PasswordHashRef::new("$2a$04$n4Uy0eSnMfvnESYL.bLwuuj0U/ETSsoTpRT9GVk$5bektyVVa5xnIi") .unwrap(); assert_eq!( - Bcrypt::default().verify_password(b"correctbatteryhorsestapler", hash), + Bcrypt.verify_password(b"correctbatteryhorsestapler", hash), Err(Error::Internal) ); // `errors_with_non_number_cost` @@ -174,7 +171,7 @@ mod tests { PasswordHashRef::new("$2a$ab$n4Uy0eSnMfvnESYL.bLwuuj0U/ETSsoTpRT9GVk$5bektyVVa5xnIi") .unwrap(); assert_eq!( - Bcrypt::default().verify_password(b"correctbatteryhorsestapler", hash), + Bcrypt.verify_password(b"correctbatteryhorsestapler", hash), Err(Error::Internal) ); // `errors_with_a_hash_too_long` @@ -183,7 +180,7 @@ mod tests { ) .unwrap(); assert_eq!( - Bcrypt::default().verify_password(b"correctbatteryhorsestapler", hash), + Bcrypt.verify_password(b"correctbatteryhorsestapler", hash), Err(Error::Internal) ); }