//! Easily hash and verify passwords using bcrypt #![forbid(unsafe_code)] #![cfg_attr(not(feature = "std"), no_std)] #[cfg(any(feature = "alloc", feature = "std", test))] extern crate alloc; #[cfg(any(feature = "alloc", feature = "std", test))] use alloc::string::String; #[cfg(feature = "zeroize")] use zeroize::Zeroize; use base64::{alphabet::BCRYPT, engine::GeneralPurpose, engine::general_purpose::NO_PAD}; use core::fmt; #[cfg(any(feature = "alloc", feature = "std"))] use {base64::Engine, core::convert::AsRef, core::str::FromStr}; mod bcrypt; mod errors; pub use crate::bcrypt::bcrypt; pub use crate::errors::{BcryptError, BcryptResult}; // Cost constants const MIN_COST: u32 = 4; const MAX_COST: u32 = 31; pub const DEFAULT_COST: u32 = 12; pub const BASE_64: GeneralPurpose = GeneralPurpose::new(&BCRYPT, NO_PAD); #[cfg(any(feature = "alloc", feature = "std"))] #[derive(Debug, PartialEq, Eq)] /// A bcrypt hash result before concatenating pub struct HashParts { cost: u32, salt: [u8; 16], hash: [u8; 23], } #[derive(Clone, Debug)] /// BCrypt hash version /// pub enum Version { TwoA, TwoX, TwoY, TwoB, } #[cfg(any(feature = "alloc", feature = "std"))] impl HashParts { /// Creates the bcrypt hash string (version 2b) into a fixed-size stack buffer. /// The full bcrypt hash string is always exactly 60 bytes. fn format(&self) -> [u8; 60] { struct ByteBuf { buf: [u8; N], pos: usize, } impl fmt::Write for ByteBuf { fn write_str(&mut self, s: &str) -> fmt::Result { let bytes = s.as_bytes(); self.buf[self.pos..self.pos + bytes.len()].copy_from_slice(bytes); self.pos += bytes.len(); Ok(()) } } let mut w = ByteBuf { buf: [0u8; 60], pos: 0, }; self.write_for_version(Version::TwoB, &mut w) .expect("writing into a correctly sized buffer is infallible"); w.buf } /// Get the bcrypt hash cost pub fn get_cost(&self) -> u32 { self.cost } /// Get the bcrypt hash salt as a base64-encoded string pub fn get_salt(&self) -> String { BASE_64.encode(self.salt) } /// Get the raw salt bytes pub fn get_salt_raw(&self) -> [u8; 16] { self.salt } /// Creates the bcrypt hash string from all its parts, allowing to customize the version. pub fn format_for_version(&self, version: Version) -> String { let mut s = String::with_capacity(60); self.write_for_version(version, &mut s) .expect("writing into a String is infallible"); s } /// Writes the bcrypt hash string into any `fmt::Write` sink without allocating. /// Useful for writing into stack buffers (e.g. `arrayvec`, `heapless::String`). pub fn write_for_version(&self, version: Version, w: &mut W) -> fmt::Result { let mut salt_buf = [0u8; 22]; let mut hash_buf = [0u8; 31]; BASE_64 .encode_slice(self.salt, &mut salt_buf) .expect("salt encoding into correctly sized buffer is infallible"); BASE_64 .encode_slice(self.hash, &mut hash_buf) .expect("hash encoding into correctly sized buffer is infallible"); write!( w, "${}${:02}${}{}", version, self.cost, core::str::from_utf8(&salt_buf).expect("base64 output is always valid UTF-8"), core::str::from_utf8(&hash_buf).expect("base64 output is always valid UTF-8") ) } } #[cfg(any(feature = "alloc", feature = "std"))] impl FromStr for HashParts { type Err = BcryptError; fn from_str(s: &str) -> Result { split_hash(s) } } #[cfg(any(feature = "alloc", feature = "std"))] impl fmt::Display for HashParts { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { self.write_for_version(Version::TwoB, f) } } impl fmt::Display for Version { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { let str = match self { Version::TwoA => "2a", Version::TwoB => "2b", Version::TwoX => "2x", Version::TwoY => "2y", }; write!(f, "{}", str) } } /// The main meat: actually does the hashing and does some verification with /// the cost to ensure it's a correct one. If err_on_truncation, this method will return /// `BcryptError::Truncation`; otherwise it will truncate the password. #[cfg(any(feature = "alloc", feature = "std"))] fn _hash_password( password: &[u8], cost: u32, salt: [u8; 16], err_on_truncation: bool, ) -> BcryptResult { if !(MIN_COST..=MAX_COST).contains(&cost) { return Err(BcryptError::CostNotAllowed(cost)); } let password_len = password.len(); if err_on_truncation && password_len >= 72 { return Err(BcryptError::Truncation(password_len + 1)); } // The bcrypt spec specifies that passwords should be null terminated // strings, but if longer than 72 bytes, are truncated at 72 bytes (thereby // losing the null byte at the end). let copy_len = password_len.min(72); let mut pass = [0u8; 72]; pass[..copy_len].copy_from_slice(&password[..copy_len]); let used = (copy_len + 1).min(72); let truncated = &pass[..used]; let output = bcrypt::bcrypt(cost, salt, truncated); #[cfg(feature = "zeroize")] pass.zeroize(); Ok(HashParts { cost, salt, hash: output[..23].try_into().unwrap(), // infallible: output is [u8; 24] }) } /// Takes a full hash and split it into 3 parts: /// cost, salt and hash #[cfg(any(feature = "alloc", feature = "std"))] fn split_hash(hash: &str) -> BcryptResult { // A valid bcrypt hash is always exactly 60 ASCII bytes. Rejecting // non-ASCII up front avoids panics from `&str` slicing through the // middle of a multi-byte UTF-8 character (regression of #62). if hash.len() != 60 || !hash.is_ascii() { return Err(BcryptError::InvalidHash( "the hash format is malformed; expected 60 ASCII bytes", )); } let bytes = hash.as_bytes(); if bytes[0] != b'$' || bytes[3] != b'$' || bytes[6] != b'$' { return Err(BcryptError::InvalidHash("the hash format is malformed")); } let version = &hash[1..3]; if version != "2y" && version != "2b" && version != "2a" && version != "2x" { return Err(BcryptError::InvalidHash( "the hash prefix is not a bcrypt prefix", )); } let cost = hash[4..6] .parse::() .map_err(|_| BcryptError::InvalidHash("the cost value is not a number"))?; let salt_and_hash = &hash[7..]; let mut salt = [0u8; 16]; let mut hash_bytes = [0u8; 23]; BASE_64 .decode_slice(&salt_and_hash[..22], &mut salt) .map_err(|_| BcryptError::InvalidHash("the salt part is not valid base64"))?; BASE_64 .decode_slice(&salt_and_hash[22..], &mut hash_bytes) .map_err(|_| BcryptError::InvalidHash("the hash part is not valid base64"))?; Ok(HashParts { cost, salt, hash: hash_bytes, }) } /// Generates a password hash using the cost given. /// The salt is generated randomly using the OS randomness #[cfg(any(feature = "alloc", feature = "std"))] pub fn hash>(password: P, cost: u32) -> BcryptResult { hash_with_result(password, cost).map(|r| { String::from( core::str::from_utf8(&r.format()).expect("base64 output is always valid UTF-8"), ) }) } /// Generates a password hash using the cost given. /// The salt is generated randomly using the OS randomness /// Will return BcryptError::Truncation if password is longer than 72 bytes #[cfg(any(feature = "alloc", feature = "std"))] pub fn non_truncating_hash>(password: P, cost: u32) -> BcryptResult { non_truncating_hash_with_result(password, cost).map(|r| { String::from( core::str::from_utf8(&r.format()).expect("base64 output is always valid UTF-8"), ) }) } /// Generates a password hash using the cost given, returning a fixed-size stack buffer. /// The salt is generated randomly using the OS randomness. /// The returned buffer is always exactly 60 bytes of valid UTF-8 (version 2b format). #[cfg(any(feature = "alloc", feature = "std"))] pub fn hash_bytes>(password: P, cost: u32) -> BcryptResult<[u8; 60]> { hash_with_result(password, cost).map(|r| r.format()) } /// Generates a password hash using the cost given, returning a fixed-size stack buffer. /// The salt is generated randomly using the OS randomness. /// The returned buffer is always exactly 60 bytes of valid UTF-8 (version 2b format). /// Will return BcryptError::Truncation if password is longer than 72 bytes #[cfg(any(feature = "alloc", feature = "std"))] pub fn non_truncating_hash_bytes>(password: P, cost: u32) -> BcryptResult<[u8; 60]> { non_truncating_hash_with_result(password, cost).map(|r| r.format()) } /// Generates a password hash using the cost given. /// The salt is generated randomly using the OS randomness. /// The function returns a result structure and allows to format the hash in different versions. #[cfg(any(feature = "alloc", feature = "std"))] pub fn hash_with_result>(password: P, cost: u32) -> BcryptResult { let salt = { let mut s = [0u8; 16]; getrandom::fill(&mut s).map(|_| s) }?; _hash_password(password.as_ref(), cost, salt, false) } /// Generates a password hash using the cost given. /// The salt is generated randomly using the OS randomness. /// The function returns a result structure and allows to format the hash in different versions. /// Will return BcryptError::Truncation if password is longer than 72 bytes #[cfg(any(feature = "alloc", feature = "std"))] pub fn non_truncating_hash_with_result>( password: P, cost: u32, ) -> BcryptResult { let salt = { let mut s = [0u8; 16]; getrandom::fill(&mut s).map(|_| s) }?; _hash_password(password.as_ref(), cost, salt, true) } /// Generates a password given a hash and a cost. /// The function returns a result structure and allows to format the hash in different versions. #[cfg(any(feature = "alloc", feature = "std"))] pub fn hash_with_salt>( password: P, cost: u32, salt: [u8; 16], ) -> BcryptResult { _hash_password(password.as_ref(), cost, salt, false) } /// Generates a password given a hash and a cost, returning a fixed-size stack buffer. /// The returned buffer is always exactly 60 bytes of valid UTF-8 (version 2b format). #[cfg(any(feature = "alloc", feature = "std"))] pub fn hash_with_salt_bytes>( password: P, cost: u32, salt: [u8; 16], ) -> BcryptResult<[u8; 60]> { _hash_password(password.as_ref(), cost, salt, false).map(|r| r.format()) } /// Generates a password given a hash and a cost. /// The function returns a result structure and allows to format the hash in different versions. /// Will return BcryptError::Truncation if password is longer than 72 bytes #[cfg(any(feature = "alloc", feature = "std"))] pub fn non_truncating_hash_with_salt>( password: P, cost: u32, salt: [u8; 16], ) -> BcryptResult { _hash_password(password.as_ref(), cost, salt, true) } /// Generates a password given a hash and a cost, returning a fixed-size stack buffer. /// The returned buffer is always exactly 60 bytes of valid UTF-8 (version 2b format). /// Will return BcryptError::Truncation if password is longer than 72 bytes #[cfg(any(feature = "alloc", feature = "std"))] pub fn non_truncating_hash_with_salt_bytes>( password: P, cost: u32, salt: [u8; 16], ) -> BcryptResult<[u8; 60]> { _hash_password(password.as_ref(), cost, salt, true).map(|r| r.format()) } /// Verify the password against the hash by extracting the salt from the hash and recomputing the /// hash from the password. If `err_on_truncation` is set to true, then this method will return /// `BcryptError::Truncation`. #[cfg(any(feature = "alloc", feature = "std"))] fn _verify>(password: P, hash: &str, err_on_truncation: bool) -> BcryptResult { use subtle::ConstantTimeEq; let parts = split_hash(hash)?; let generated = _hash_password(password.as_ref(), parts.cost, parts.salt, err_on_truncation)?; Ok(parts.hash.ct_eq(&generated.hash).into()) } /// Verify that a password is equivalent to the hash provided #[cfg(any(feature = "alloc", feature = "std"))] pub fn verify>(password: P, hash: &str) -> BcryptResult { _verify(password, hash, false) } /// Verify that a password is equivalent to the hash provided. /// Only use this if you are only using `non_truncating_hash` to generate the hash. /// It will return an error for inputs that will work if generated from other sources. #[cfg(any(feature = "alloc", feature = "std"))] pub fn non_truncating_verify>(password: P, hash: &str) -> BcryptResult { _verify(password, hash, true) } #[cfg(all(test, any(feature = "alloc", feature = "std")))] mod tests { use crate::non_truncating_hash; use super::{ _hash_password, BcryptError, BcryptResult, DEFAULT_COST, HashParts, Version, alloc::{ string::{String, ToString}, vec, vec::Vec, }, hash, hash_bytes, hash_with_salt, hash_with_salt_bytes, non_truncating_hash_bytes, non_truncating_hash_with_salt_bytes, non_truncating_verify, split_hash, verify, }; use base64::Engine as _; use core::convert::TryInto; use core::iter; use core::str::FromStr; use quickcheck::{TestResult, quickcheck}; #[test] fn can_split_hash() { let hash = "$2y$12$L6Bc/AlTQHyd9liGgGEZyOFLPHNgyxeEPfgYfBCVxJ7JIlwxyVU3u"; let output = split_hash(hash).unwrap(); assert_eq!(output.get_cost(), 12); assert_eq!(output.get_salt(), "L6Bc/AlTQHyd9liGgGEZyO"); assert_eq!( output.format_for_version(Version::TwoY), "$2y$12$L6Bc/AlTQHyd9liGgGEZyOFLPHNgyxeEPfgYfBCVxJ7JIlwxyVU3u" ); } #[test] fn can_output_cost_and_salt_from_parsed_hash() { let hash = "$2y$12$L6Bc/AlTQHyd9liGgGEZyOFLPHNgyxeEPfgYfBCVxJ7JIlwxyVU3u"; let parsed = HashParts::from_str(hash).unwrap(); assert_eq!(parsed.get_cost(), 12); assert_eq!(parsed.get_salt(), "L6Bc/AlTQHyd9liGgGEZyO".to_string()); } #[test] fn can_get_raw_salt_from_parsed_hash() { let hash = "$2y$12$L6Bc/AlTQHyd9liGgGEZyOFLPHNgyxeEPfgYfBCVxJ7JIlwxyVU3u"; let parsed = HashParts::from_str(hash).unwrap(); // Raw salt must round-trip back to the same base64 string assert_eq!( super::BASE_64.encode(parsed.get_salt_raw()), "L6Bc/AlTQHyd9liGgGEZyO" ); } #[test] fn can_write_hash_for_version_without_allocating() { let hash = "$2y$12$L6Bc/AlTQHyd9liGgGEZyOFLPHNgyxeEPfgYfBCVxJ7JIlwxyVU3u"; let parsed = HashParts::from_str(hash).unwrap(); let mut buf = String::new(); parsed.write_for_version(Version::TwoY, &mut buf).unwrap(); assert_eq!(buf, hash); } #[test] fn write_for_version_matches_format_for_version() { let salt = [0u8; 16]; let result = _hash_password("hunter2".as_bytes(), DEFAULT_COST, salt, false).unwrap(); let formatted = result.format_for_version(Version::TwoA); let mut written = String::new(); result .write_for_version(Version::TwoA, &mut written) .unwrap(); assert_eq!(formatted, written); } #[test] fn returns_an_error_if_a_parsed_hash_is_baddly_formated() { let hash1 = "$2y$12$L6Bc/AlTQHyd9lGEZyOFLPHNgyxeEPfgYfBCVxJ7JIlwxyVU3u"; assert!(HashParts::from_str(hash1).is_err()); let hash2 = "!2y$12$L6Bc/AlTQHyd9liGgGEZyOFLPHNgyxeEPfgYfBCVxJ7JIlwxyVU3u"; assert!(HashParts::from_str(hash2).is_err()); let hash3 = "$2y$-12$L6Bc/AlTQHyd9liGgGEZyOFLPHNgyxeEPfgYfBCVxJ7JIlwxyVU3u"; assert!(HashParts::from_str(hash3).is_err()); } #[test] fn can_verify_hash_generated_from_some_online_tool() { let hash = "$2a$04$UuTkLRZZ6QofpDOlMz32MuuxEHA43WOemOYHPz6.SjsVsyO1tDU96"; assert!(verify("password", hash).unwrap()); } #[test] fn can_verify_hash_generated_from_python() { let hash = "$2b$04$EGdrhbKUv8Oc9vGiXX0HQOxSg445d458Muh7DAHskb6QbtCvdxcie"; assert!(verify("correctbatteryhorsestapler", hash).unwrap()); } #[test] fn can_verify_hash_generated_from_node() { let hash = "$2a$04$n4Uy0eSnMfvnESYL.bLwuuj0U/ETSsoTpRT9GVk5bektyVVa5xnIi"; assert!(verify("correctbatteryhorsestapler", hash).unwrap()); } #[test] fn can_verify_hash_generated_from_go() { /* package main import ( "io" "os" "golang.org/x/crypto/bcrypt" ) func main() { buf, err := io.ReadAll(os.Stdin) if err != nil { panic(err) } out, err := bcrypt.GenerateFromPassword(buf, bcrypt.MinCost) if err != nil { panic(err) } os.Stdout.Write(out) os.Stdout.Write([]byte("\n")) } */ let binary_input = vec![ 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 = "$2a$04$tjARW6ZON3PhrAIRW2LG/u9aDw5eFdstYLR8nFCNaOQmsH9XD23w."; assert!(verify(binary_input, hash).unwrap()); } #[test] fn invalid_hash_does_not_panic() { let binary_input = vec![ 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 = "$2a$04$tjARW6ZON3PhrAIRW2LG/u9a."; assert!(verify(binary_input, hash).is_err()); } #[test] fn a_wrong_password_is_false() { let hash = "$2b$04$EGdrhbKUv8Oc9vGiXX0HQOxSg445d458Muh7DAHskb6QbtCvdxcie"; assert!(!verify("wrong", hash).unwrap()); } #[test] fn errors_with_invalid_hash() { // there is another $ in the hash part let hash = "$2a$04$n4Uy0eSnMfvnESYL.bLwuuj0U/ETSsoTpRT9GVk$5bektyVVa5xnIi"; assert!(verify("correctbatteryhorsestapler", hash).is_err()); } #[test] fn errors_with_non_number_cost() { // the cost is not a number let hash = "$2a$ab$n4Uy0eSnMfvnESYL.bLwuuj0U/ETSsoTpRT9GVk$5bektyVVa5xnIi"; assert!(verify("correctbatteryhorsestapler", hash).is_err()); } #[test] fn errors_with_a_hash_too_long() { // the cost is not a number let hash = "$2a$04$n4Uy0eSnMfvnESYL.bLwuuj0U/ETSsoTpRT9GVk$5bektyVVa5xnIerererereri"; assert!(verify("correctbatteryhorsestapler", hash).is_err()); } #[test] fn can_verify_own_generated() { let hashed = hash("hunter2", 4).unwrap(); assert_eq!(true, verify("hunter2", &hashed).unwrap()); } #[test] fn long_passwords_truncate_correctly() { // produced with python -c 'import bcrypt; bcrypt.hashpw(b"x"*100, b"$2a$05$...............................")' let hash = "$2a$05$......................YgIDy4hFBdVlc/6LHnD9mX488r9cLd2"; assert!(verify(iter::repeat("x").take(100).collect::(), hash).unwrap()); } #[test] fn non_truncating_operations() { assert!(matches!( non_truncating_hash(iter::repeat("x").take(72).collect::(), DEFAULT_COST), BcryptResult::Err(BcryptError::Truncation(73)) )); assert!(matches!( non_truncating_hash(iter::repeat("x").take(71).collect::(), DEFAULT_COST), BcryptResult::Ok(_) )); let hash = "$2a$05$......................YgIDy4hFBdVlc/6LHnD9mX488r9cLd2"; assert!(matches!( non_truncating_verify(iter::repeat("x").take(100).collect::(), hash), Err(BcryptError::Truncation(101)) )); } #[test] fn generate_versions() { let password = "hunter2".as_bytes(); let salt = vec![0; 16]; let result = _hash_password(password, DEFAULT_COST, salt.try_into().unwrap(), false).unwrap(); assert_eq!( "$2a$12$......................21jzCB1r6pN6rp5O2Ev0ejjTAboskKm", result.format_for_version(Version::TwoA) ); assert_eq!( "$2b$12$......................21jzCB1r6pN6rp5O2Ev0ejjTAboskKm", result.format_for_version(Version::TwoB) ); assert_eq!( "$2x$12$......................21jzCB1r6pN6rp5O2Ev0ejjTAboskKm", result.format_for_version(Version::TwoX) ); assert_eq!( "$2y$12$......................21jzCB1r6pN6rp5O2Ev0ejjTAboskKm", result.format_for_version(Version::TwoY) ); let hash = result.to_string(); assert_eq!(true, verify("hunter2", &hash).unwrap()); } #[test] fn allow_null_bytes() { // hash p1, check the hash against p2: fn hash_and_check(p1: &[u8], p2: &[u8]) -> Result { let fast_cost = 4; match hash(p1, fast_cost) { Ok(s) => verify(p2, &s), Err(e) => Err(e), } } fn assert_valid_password(p1: &[u8], p2: &[u8], expected: bool) { match hash_and_check(p1, p2) { Ok(checked) => { if checked != expected { panic!( "checked {:?} against {:?}, incorrect result {}", p1, p2, checked ) } } Err(e) => panic!("error evaluating password: {} for {:?}.", e, p1), } } // bcrypt should consider all of these distinct: let test_passwords = vec![ "\0", "passw0rd\0", "password\0with tail", "\0passw0rd", "a", "a\0", "a\0b\0", ]; for (i, p1) in test_passwords.iter().enumerate() { for (j, p2) in test_passwords.iter().enumerate() { assert_valid_password(p1.as_bytes(), p2.as_bytes(), i == j); } } // this is a quirk of the bcrypt algorithm: passwords that are entirely null // bytes hash to the same value, even if they are different lengths: assert_valid_password("\0\0\0\0\0\0\0\0".as_bytes(), "\0".as_bytes(), true); } #[test] fn hash_with_fixed_salt() { let salt = [ 38, 113, 212, 141, 108, 213, 195, 166, 201, 38, 20, 13, 47, 40, 104, 18, ]; let hashed = hash_with_salt("My S3cre7 P@55w0rd!", 5, salt) .unwrap() .to_string(); assert_eq!( "$2b$05$HlFShUxTu4ZHHfOLJwfmCeDj/kuKFKboanXtDJXxCC7aIPTUgxNDe", &hashed ); } #[test] fn hash_bytes_returns_valid_utf8_bcrypt_string() { let result = hash_bytes("hunter2", 4).unwrap(); let s = core::str::from_utf8(&result).unwrap(); assert!(s.starts_with("$2b$04$")); assert_eq!(s.len(), 60); assert!(verify("hunter2", s).unwrap()); } #[test] fn non_truncating_hash_bytes_returns_valid_utf8_bcrypt_string() { let result = non_truncating_hash_bytes("hunter2", 4).unwrap(); let s = core::str::from_utf8(&result).unwrap(); assert!(s.starts_with("$2b$04$")); assert_eq!(s.len(), 60); assert!(verify("hunter2", s).unwrap()); } #[test] fn non_truncating_hash_bytes_errors_on_long_password() { use core::iter; let result = non_truncating_hash_bytes(iter::repeat("x").take(72).collect::(), 4); assert!(matches!(result, Err(BcryptError::Truncation(73)))); } #[test] fn hash_with_salt_bytes_matches_hash_with_salt() { let salt = [ 38, 113, 212, 141, 108, 213, 195, 166, 201, 38, 20, 13, 47, 40, 104, 18, ]; let expected = hash_with_salt("My S3cre7 P@55w0rd!", 5, salt) .unwrap() .to_string(); let result = hash_with_salt_bytes("My S3cre7 P@55w0rd!", 5, salt).unwrap(); let s = core::str::from_utf8(&result).unwrap(); assert_eq!(expected, s); } #[test] fn non_truncating_hash_with_salt_bytes_errors_on_long_password() { use core::iter; let salt = [0u8; 16]; let result = non_truncating_hash_with_salt_bytes( iter::repeat("x").take(72).collect::(), 4, salt, ); assert!(matches!(result, Err(BcryptError::Truncation(73)))); } #[test] fn hash_bytes_matches_hash_string() { let salt = [0u8; 16]; let result_parts = _hash_password("hunter2".as_bytes(), 4, salt, false).unwrap(); let from_parts = result_parts.format_for_version(Version::TwoB); let bytes_result = hash_with_salt_bytes("hunter2", 4, salt).unwrap(); let from_bytes = core::str::from_utf8(&bytes_result).unwrap(); assert_eq!(from_parts, from_bytes); } quickcheck! { fn can_verify_arbitrary_own_generated(pass: Vec) -> BcryptResult { let mut pass = pass; pass.retain(|&b| b != 0); let hashed = hash(&pass, 4)?; verify(pass, &hashed) } fn doesnt_verify_different_passwords(a: Vec, b: Vec) -> BcryptResult { let mut a = a; a.retain(|&b| b != 0); let mut b = b; b.retain(|&b| b != 0); if a == b { return Ok(TestResult::discard()); } let hashed = hash(a, 4)?; Ok(TestResult::from_bool(!verify(b, &hashed)?)) } } #[test] fn does_no_error_on_char_boundary_splitting() { // Just checks that it does not panic let _ = verify( &[], "2a$$$0$OOOOOOOOOOOOOOOOOOOOO£OOOOOOOOOOOOOOOOOOOOOOOOOOOOOO", ); } #[test] fn verify_rejects_multibyte_utf8_in_hash() { // Constructed so byte position 22 falls inside the multi-byte // sequence for '£' (0xC2 0xA3). Before this fix, this hash would // panic in str::slice_error_fail when split_hash sliced // &salt_and_hash[..22]. After: returns InvalidHash, like every // other malformed input. Regression test for #62. let hash = "$2b$04$aaaaaaaaaaaaaaaaaaaaa£aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; assert_eq!(hash.len(), 60); // sanity: byte length is still 60 assert!(matches!( verify(&b"password"[..], hash), Err(BcryptError::InvalidHash(_)) )); } #[test] fn split_hash_rejects_non_ascii() { // Direct parser-level test of the invariant: any non-ASCII byte // anywhere in a 60-byte hash string is rejected up front. let hash = "$2b$04$aaaaaaaaaaaaaaaaaaaaa£aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; assert!(matches!(split_hash(hash), Err(BcryptError::InvalidHash(_)))); } }