diff --git a/Cargo.toml b/Cargo.toml index 853dca8..30f7886 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,9 +12,11 @@ edition = "2024" include = ["src/**/*", "LICENSE", "README.md"] [features] -default = ["std", "zeroize"] +default = ["std", "zeroize", "kdf", "password-hash"] std = ["getrandom/std", "base64/std"] alloc = ["base64/alloc", "getrandom"] +kdf = ["dep:kdf"] +password-hash = ["dep:mcf", "dep:password-hash", "alloc"] [dependencies] blowfish = { version = "0.9", features = ["bcrypt"] } @@ -22,6 +24,9 @@ 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 } +kdf = { version = "0.1.0", optional = true } +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..15f6877 100644 --- a/src/bcrypt.rs +++ b/src/bcrypt.rs @@ -1,4 +1,9 @@ use blowfish::Blowfish; +#[cfg(feature = "kdf")] +use kdf::{Kdf, Pbkdf}; + +#[cfg(any(feature = "kdf", feature = "password-hash"))] +use crate::BcryptError; fn setup(cost: u32, salt: &[u8], key: &[u8]) -> Blowfish { assert!(cost < 32); @@ -41,9 +46,51 @@ pub fn bcrypt(cost: u32, salt: [u8; 16], password: &[u8]) -> [u8; 24] { output } +#[cfg(any(feature = "kdf", feature = "password-hash"))] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +/// bcrypt context. +pub struct Bcrypt { + pub(crate) cost: u32, +} + +#[cfg(any(feature = "kdf", 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(any(feature = "kdf", feature = "password-hash"))] +impl Default for Bcrypt { + fn default() -> Self { + Self { + cost: crate::DEFAULT_COST, + } + } +} + +#[cfg(feature = "kdf")] +impl Kdf for Bcrypt { + fn derive_key(&self, password: &[u8], salt: &[u8], out: &mut [u8]) -> kdf::Result<()> { + let salt = salt.try_into().map_err(|_| kdf::Error)?; + let hash = bcrypt(self.cost, salt, password); + out.copy_from_slice(&hash); + Ok(()) + } +} + +#[cfg(feature = "kdf")] +impl Pbkdf for Bcrypt {} + #[cfg(test)] mod tests { use super::bcrypt; + #[cfg(feature = "kdf")] + use super::{Bcrypt, Kdf}; #[test] fn raw_bcrypt() { @@ -89,4 +136,59 @@ mod tests { ]; assert_eq!(bcrypt(cost, salt, pw)[..23], result); } + + #[cfg(feature = "kdf")] + #[test] + fn derive_key() { + // test vectors unbase64ed from + // https://github.com/djmdjm/jBCrypt/blob/master/test/org/mindrot/jbcrypt/TestBCrypt.java + + // $2a$06$DCq7YPn5Rq63x1Lad4cll.TV4S6ytwfsfvkgY8jIucDrjc8deX1s. + let pw = b"\0"; + let cost = 6; + let salt = [ + 0x14, 0x4b, 0x3d, 0x69, 0x1a, 0x7b, 0x4e, 0xcf, 0x39, 0xcf, 0x73, 0x5c, 0x7f, 0xa7, + 0xa7, 0x9c, + ]; + let result = [ + 0x55, 0x7e, 0x94, 0xf3, 0x4b, 0xf2, 0x86, 0xe8, 0x71, 0x9a, 0x26, 0xbe, 0x94, 0xac, + 0x1e, 0x16, 0xd9, 0x5e, 0xf9, 0xf8, 0x19, 0xde, 0xe0, + ]; + let bcrypt = Bcrypt::new(cost).unwrap(); + let mut output = [0_u8; 24]; + bcrypt.derive_key(pw, &salt, &mut output).unwrap(); + assert_eq!(output[..23], result); + + // $2a$06$m0CrhHm10qJ3lXRY.5zDGO3rS2KdeeWLuGmsfGlMfOxih58VYVfxe + let pw = b"a\0"; + let cost = 6; + let salt = [ + 0xa3, 0x61, 0x2d, 0x8c, 0x9a, 0x37, 0xda, 0xc2, 0xf9, 0x9d, 0x94, 0xda, 0x3, 0xbd, + 0x45, 0x21, + ]; + let result = [ + 0xe6, 0xd5, 0x38, 0x31, 0xf8, 0x20, 0x60, 0xdc, 0x8, 0xa2, 0xe8, 0x48, 0x9c, 0xe8, + 0x50, 0xce, 0x48, 0xfb, 0xf9, 0x76, 0x97, 0x87, 0x38, + ]; + let bcrypt = Bcrypt::new(cost).unwrap(); + let mut output = [0_u8; 24]; + bcrypt.derive_key(pw, &salt, &mut output).unwrap(); + assert_eq!(output[..23], result); + + // // $2a$08$aTsUwsyowQuzRrDqFflhgekJ8d9/7Z3GV3UcgvzQW3J5zMyrTvlz. + let pw = b"abcdefghijklmnopqrstuvwxyz\0"; + let cost = 8; + let salt = [ + 0x71, 0x5b, 0x96, 0xca, 0xed, 0x2a, 0xc9, 0x2c, 0x35, 0x4e, 0xd1, 0x6c, 0x1e, 0x19, + 0xe3, 0x8a, + ]; + let result = [ + 0x98, 0xbf, 0x9f, 0xfc, 0x1f, 0x5b, 0xe4, 0x85, 0xf9, 0x59, 0xe8, 0xb1, 0xd5, 0x26, + 0x39, 0x2f, 0xbd, 0x4e, 0xd2, 0xd5, 0x71, 0x9f, 0x50, + ]; + let bcrypt = Bcrypt::new(cost).unwrap(); + let mut output = [0_u8; 24]; + bcrypt.derive_key(pw, &salt, &mut output).unwrap(); + assert_eq!(output[..23], result); + } } diff --git a/src/lib.rs b/src/lib.rs index 9488a8c..5b5a575 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -16,9 +16,18 @@ use core::fmt; #[cfg(any(feature = "alloc", feature = "std"))] use {base64::Engine, core::convert::AsRef, core::str::FromStr}; +#[cfg(feature = "kdf")] +pub use kdf::{self, Kdf, Pbkdf}; +#[cfg(feature = "password-hash")] +pub use password_hash; + mod bcrypt; mod errors; +#[cfg(feature = "password-hash")] +pub mod mcf; +#[cfg(any(feature = "kdf", 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) + ); + } +}