Skip to content

Commit 1cdfc22

Browse files
authored
Use feature "error_on_truncation" to send error on truncation (#89)
* Use feature "error_on_truncation" to send error on truncation * Added documentation * Mentioned most implementations truncate by default; one should enable error_on_truncation after careful consideration * Revert error_on_truncation feature * Users can choose to enforce truncation using non_truncating_* implementations * verify and non_truncating_verify both call _verify, only with different value for err_on_truncation
1 parent 9c9e138 commit 1cdfc22

File tree

3 files changed

+107
-11
lines changed

3 files changed

+107
-11
lines changed

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ let valid = verify("hunter2", &hashed)?;
2727

2828
The cost needs to be an integer between 4 and 31 (see benchmarks to have an idea of the speed for each), the `DEFAULT_COST` is 12.
2929

30+
## Error on truncation
31+
Most if not all bcrypt implementation truncates the password after 72 bytes. In specific use cases this can break 2nd pre-image resistance. One can enforce the 72-bytes limit on input by using `non_truncating_hash`, `non_truncating_hash_with_result`, `non_truncating_hash_with_salt`, and `non_truncating_verify`. The `non_truncating_*` functions behave identically to their truncating counterparts unless the input is longer than 72 bytes, in which case they will return `BcryptError::Truncation`.
32+
3033
## `no_std`
3134

3235
`bcrypt` crate supports `no_std` platforms. When `alloc` feature is enabled,

src/errors.rs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ pub enum BcryptError {
2727
InvalidBase64(base64::DecodeError),
2828
#[cfg(any(feature = "alloc", feature = "std"))]
2929
Rand(getrandom::Error),
30+
/// Return this error if the input contains more than 72 bytes. This variant contains the
31+
/// length of the input in bytes.
32+
Truncation(usize),
3033
}
3134

3235
macro_rules! impl_from_error {
@@ -69,6 +72,9 @@ impl fmt::Display for BcryptError {
6972
}
7073
#[cfg(any(feature = "alloc", feature = "std"))]
7174
BcryptError::Rand(ref err) => write!(f, "Rand error: {}", err),
75+
BcryptError::Truncation(len) => {
76+
write!(f, "Expected 72 bytes or fewer; found {len} bytes")
77+
}
7278
}
7379
}
7480
}
@@ -82,7 +88,8 @@ impl error::Error for BcryptError {
8288
| BcryptError::CostNotAllowed(_)
8389
| BcryptError::InvalidPrefix(_)
8490
| BcryptError::InvalidHash(_)
85-
| BcryptError::InvalidSaltLen(_) => None,
91+
| BcryptError::InvalidSaltLen(_)
92+
| BcryptError::Truncation(_) => None,
8693
BcryptError::InvalidBase64(ref err) => Some(err),
8794
BcryptError::Rand(ref err) => Some(err),
8895
}

src/lib.rs

Lines changed: 96 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -103,9 +103,15 @@ impl fmt::Display for Version {
103103
}
104104

105105
/// The main meat: actually does the hashing and does some verification with
106-
/// the cost to ensure it's a correct one
106+
/// the cost to ensure it's a correct one. If err_on_truncation, this method will return
107+
/// `BcryptError::Truncation`; otherwise it will truncate the password.
107108
#[cfg(any(feature = "alloc", feature = "std"))]
108-
fn _hash_password(password: &[u8], cost: u32, salt: [u8; 16]) -> BcryptResult<HashParts> {
109+
fn _hash_password(
110+
password: &[u8],
111+
cost: u32,
112+
salt: [u8; 16],
113+
err_on_truncation: bool,
114+
) -> BcryptResult<HashParts> {
109115
if !(MIN_COST..=MAX_COST).contains(&cost) {
110116
return Err(BcryptError::CostNotAllowed(cost));
111117
}
@@ -116,7 +122,14 @@ fn _hash_password(password: &[u8], cost: u32, salt: [u8; 16]) -> BcryptResult<Ha
116122
vec.push(0);
117123
// We only consider the first 72 chars; truncate if necessary.
118124
// `bcrypt` below will panic if len > 72
119-
let truncated = if vec.len() > 72 { &vec[..72] } else { &vec };
125+
let truncated = if vec.len() > 72 {
126+
if err_on_truncation {
127+
return Err(BcryptError::Truncation(vec.len()));
128+
}
129+
&vec[..72]
130+
} else {
131+
&vec
132+
};
120133

121134
let output = bcrypt::bcrypt(cost, salt, truncated);
122135

@@ -175,6 +188,14 @@ pub fn hash<P: AsRef<[u8]>>(password: P, cost: u32) -> BcryptResult<String> {
175188
hash_with_result(password, cost).map(|r| r.format())
176189
}
177190

191+
/// Generates a password hash using the cost given.
192+
/// The salt is generated randomly using the OS randomness
193+
/// Will return BcryptError::Truncation if password is longer than 72 bytes
194+
#[cfg(any(feature = "alloc", feature = "std"))]
195+
pub fn non_truncating_hash<P: AsRef<[u8]>>(password: P, cost: u32) -> BcryptResult<String> {
196+
non_truncating_hash_with_result(password, cost).map(|r| r.format())
197+
}
198+
178199
/// Generates a password hash using the cost given.
179200
/// The salt is generated randomly using the OS randomness.
180201
/// The function returns a result structure and allows to format the hash in different versions.
@@ -185,7 +206,24 @@ pub fn hash_with_result<P: AsRef<[u8]>>(password: P, cost: u32) -> BcryptResult<
185206
getrandom(&mut s).map(|_| s)
186207
}?;
187208

188-
_hash_password(password.as_ref(), cost, salt)
209+
_hash_password(password.as_ref(), cost, salt, false)
210+
}
211+
212+
/// Generates a password hash using the cost given.
213+
/// The salt is generated randomly using the OS randomness.
214+
/// The function returns a result structure and allows to format the hash in different versions.
215+
/// Will return BcryptError::Truncation if password is longer than 72 bytes
216+
#[cfg(any(feature = "alloc", feature = "std"))]
217+
pub fn non_truncating_hash_with_result<P: AsRef<[u8]>>(
218+
password: P,
219+
cost: u32,
220+
) -> BcryptResult<HashParts> {
221+
let salt = {
222+
let mut s = [0u8; 16];
223+
getrandom(&mut s).map(|_| s)
224+
}?;
225+
226+
_hash_password(password.as_ref(), cost, salt, true)
189227
}
190228

191229
/// Generates a password given a hash and a cost.
@@ -196,12 +234,26 @@ pub fn hash_with_salt<P: AsRef<[u8]>>(
196234
cost: u32,
197235
salt: [u8; 16],
198236
) -> BcryptResult<HashParts> {
199-
_hash_password(password.as_ref(), cost, salt)
237+
_hash_password(password.as_ref(), cost, salt, false)
200238
}
201239

202-
/// Verify that a password is equivalent to the hash provided
240+
/// Generates a password given a hash and a cost.
241+
/// The function returns a result structure and allows to format the hash in different versions.
242+
/// Will return BcryptError::Truncation if password is longer than 72 bytes
203243
#[cfg(any(feature = "alloc", feature = "std"))]
204-
pub fn verify<P: AsRef<[u8]>>(password: P, hash: &str) -> BcryptResult<bool> {
244+
pub fn non_truncating_hash_with_salt<P: AsRef<[u8]>>(
245+
password: P,
246+
cost: u32,
247+
salt: [u8; 16],
248+
) -> BcryptResult<HashParts> {
249+
_hash_password(password.as_ref(), cost, salt, true)
250+
}
251+
252+
/// Verify the password against the hash by extracting the salt from the hash and recomputing the
253+
/// hash from the password. If `err_on_truncation` is set to true, then this method will return
254+
/// `BcryptError::Truncation`.
255+
#[cfg(any(feature = "alloc", feature = "std"))]
256+
fn _verify<P: AsRef<[u8]>>(password: P, hash: &str, err_on_truncation: bool) -> BcryptResult<bool> {
205257
use subtle::ConstantTimeEq;
206258

207259
let parts = split_hash(hash)?;
@@ -212,24 +264,39 @@ pub fn verify<P: AsRef<[u8]>>(password: P, hash: &str) -> BcryptResult<bool> {
212264
parts.cost,
213265
salt.try_into()
214266
.map_err(|_| BcryptError::InvalidSaltLen(salt_len))?,
267+
err_on_truncation,
215268
)?;
216269
let source_decoded = BASE_64.decode(parts.hash)?;
217270
let generated_decoded = BASE_64.decode(generated.hash)?;
218271

219272
Ok(source_decoded.ct_eq(&generated_decoded).into())
220273
}
221274

275+
/// Verify that a password is equivalent to the hash provided
276+
#[cfg(any(feature = "alloc", feature = "std"))]
277+
pub fn verify<P: AsRef<[u8]>>(password: P, hash: &str) -> BcryptResult<bool> {
278+
_verify(password, hash, false)
279+
}
280+
281+
/// Verify that a password is equivalent to the hash provided
282+
#[cfg(any(feature = "alloc", feature = "std"))]
283+
pub fn non_truncating_verify<P: AsRef<[u8]>>(password: P, hash: &str) -> BcryptResult<bool> {
284+
_verify(password, hash, true)
285+
}
286+
222287
#[cfg(all(test, any(feature = "alloc", feature = "std")))]
223288
mod tests {
289+
use crate::non_truncating_hash;
290+
224291
use super::{
225292
_hash_password,
226293
alloc::{
227294
string::{String, ToString},
228295
vec,
229296
vec::Vec,
230297
},
231-
hash, hash_with_salt, split_hash, verify, BcryptError, BcryptResult, HashParts, Version,
232-
DEFAULT_COST,
298+
hash, hash_with_salt, non_truncating_verify, split_hash, verify, BcryptError, BcryptResult,
299+
HashParts, Version, DEFAULT_COST,
233300
};
234301
use core::convert::TryInto;
235302
use core::iter;
@@ -366,11 +433,30 @@ mod tests {
366433
assert!(verify(iter::repeat("x").take(100).collect::<String>(), hash).unwrap());
367434
}
368435

436+
#[test]
437+
fn non_truncating_operations() {
438+
assert!(matches!(
439+
non_truncating_hash(iter::repeat("x").take(72).collect::<String>(), DEFAULT_COST),
440+
BcryptResult::Err(BcryptError::Truncation(73))
441+
));
442+
assert!(matches!(
443+
non_truncating_hash(iter::repeat("x").take(71).collect::<String>(), DEFAULT_COST),
444+
BcryptResult::Ok(_)
445+
));
446+
447+
let hash = "$2a$05$......................YgIDy4hFBdVlc/6LHnD9mX488r9cLd2";
448+
assert!(matches!(
449+
non_truncating_verify(iter::repeat("x").take(100).collect::<String>(), hash),
450+
Err(BcryptError::Truncation(101))
451+
));
452+
}
453+
369454
#[test]
370455
fn generate_versions() {
371456
let password = "hunter2".as_bytes();
372457
let salt = vec![0; 16];
373-
let result = _hash_password(password, DEFAULT_COST, salt.try_into().unwrap()).unwrap();
458+
let result =
459+
_hash_password(password, DEFAULT_COST, salt.try_into().unwrap(), false).unwrap();
374460
assert_eq!(
375461
"$2a$12$......................21jzCB1r6pN6rp5O2Ev0ejjTAboskKm",
376462
result.format_for_version(Version::TwoA)

0 commit comments

Comments
 (0)