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 853dca8..df12530 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ include = ["src/**/*", "LICENSE", "README.md"] default = ["std", "zeroize"] 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..b19db4c 100644 --- a/src/bcrypt.rs +++ b/src/bcrypt.rs @@ -41,6 +41,11 @@ pub fn bcrypt(cost: u32, salt: [u8; 16], password: &[u8]) -> [u8; 24] { output } +#[cfg(feature = "password-hash")] +#[derive(Clone, Copy, Debug)] +/// bcrypt context. +pub struct Bcrypt; + #[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..50a6a64 --- /dev/null +++ b/src/mcf.rs @@ -0,0 +1,187 @@ +//! 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, + cost: 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, cost, 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, crate::DEFAULT_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 + .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 + .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 + .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 + .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.verify_password(b"password", hash), Ok(())); + // `can_verify_hash_generated_from_python` + let hash = + PasswordHashRef::new("$2b$04$EGdrhbKUv8Oc9vGiXX0HQOxSg445d458Muh7DAHskb6QbtCvdxcie") + .unwrap(); + assert_eq!( + Bcrypt.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.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.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.verify_password(&binary_input, hash), + Err(Error::Internal) + ); + // `a_wrong_password_is_false` + let hash = + PasswordHashRef::new("$2b$04$EGdrhbKUv8Oc9vGiXX0HQOxSg445d458Muh7DAHskb6QbtCvdxcie") + .unwrap(); + assert_eq!( + Bcrypt.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.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.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.verify_password(b"correctbatteryhorsestapler", hash), + Err(Error::Internal) + ); + } +}