Skip to content

Commit 7c232a7

Browse files
authored
pbkdf2: fix MCF Base64; add SHA-512 MCF support (#808)
I discovered these test vectors from a Go port of Passlib: https://github.com/hlandau/passlib/blob/8f820e0/hash/pbkdf2/pbkdf2_test.go ...and also checked against the Python implementation of Passlib itself, and discovered PBKDF2 seems to use its own variant of Base64 which is distinct from the ones used by bcrypt/crypt, namely it's a variant of unpadded Base64 which swaps `+` for `.` This implements this Base64 variant, and also adds a test vector from the Go implementation of Passlib for PBKDF2-SHA-512.
1 parent 53498b3 commit 7c232a7

4 files changed

Lines changed: 97 additions & 30 deletions

File tree

pbkdf2/src/lib.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,9 @@
9393
//! # }
9494
//! ```
9595
96+
#[cfg(feature = "mcf")]
97+
extern crate alloc;
98+
9699
#[cfg(feature = "mcf")]
97100
pub mod mcf;
98101
#[cfg(feature = "phc")]
@@ -270,6 +273,15 @@ pub struct Pbkdf2 {
270273
params: Params,
271274
}
272275

276+
#[cfg(any(feature = "mcf", feature = "phc"))]
277+
impl Pbkdf2 {
278+
/// PBKDF2 configured with SHA-256 as the default.
279+
pub const SHA256: Self = Self::new(Algorithm::Pbkdf2Sha256, Params::RECOMMENDED);
280+
281+
/// PBKDF2 configured with SHA-512 as the default.
282+
pub const SHA512: Self = Self::new(Algorithm::Pbkdf2Sha512, Params::RECOMMENDED);
283+
}
284+
273285
#[cfg(any(feature = "mcf", feature = "phc"))]
274286
impl Pbkdf2 {
275287
/// Initialize [`Pbkdf2`] with default parameters.

pbkdf2/src/mcf.rs

Lines changed: 55 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,16 @@
66
pub use mcf::{PasswordHash, PasswordHashRef};
77

88
use crate::{Algorithm, Params, Pbkdf2, pbkdf2_hmac};
9+
use alloc::string::String;
910
use mcf::Base64;
1011
use password_hash::{CustomizedPasswordHasher, Error, PasswordHasher, Result, Version};
1112
use sha2::{Sha256, Sha512};
1213

1314
#[cfg(feature = "sha1")]
1415
use sha1::Sha1;
1516

16-
/// Base64 variant used by PBKDF2's MCF implementation: unpadded standard Base64.
17-
const PBKDF2_BASE64: Base64 = Base64::B64;
17+
#[cfg(test)]
18+
use alloc::vec::Vec;
1819

1920
impl CustomizedPasswordHasher<PasswordHash> for Pbkdf2 {
2021
type Params = Params;
@@ -38,7 +39,7 @@ impl CustomizedPasswordHasher<PasswordHash> for Pbkdf2 {
3839

3940
let mut buffer = [0u8; Params::MAX_LENGTH];
4041
let out = buffer
41-
.get_mut(..params.output_length)
42+
.get_mut(..params.output_len())
4243
.ok_or(Error::OutputSize)?;
4344

4445
let f = match algorithm {
@@ -48,15 +49,21 @@ impl CustomizedPasswordHasher<PasswordHash> for Pbkdf2 {
4849
Algorithm::Pbkdf2Sha512 => pbkdf2_hmac::<Sha512>,
4950
};
5051

51-
f(password, salt, params.rounds, out);
52+
f(password, salt, params.rounds(), out);
5253

5354
let mut mcf_hash = PasswordHash::from_id(algorithm.to_str()).expect("should have valid ID");
5455

5556
mcf_hash
5657
.push_displayable(params)
5758
.map_err(|_| Error::EncodingInvalid)?;
58-
mcf_hash.push_base64(salt, PBKDF2_BASE64);
59-
mcf_hash.push_base64(out, PBKDF2_BASE64);
59+
60+
mcf_hash
61+
.push_str(&base64_encode(salt))
62+
.map_err(|_| Error::EncodingInvalid)?;
63+
64+
mcf_hash
65+
.push_str(&base64_encode(out))
66+
.map_err(|_| Error::EncodingInvalid)?;
6067

6168
Ok(mcf_hash)
6269
}
@@ -68,26 +75,39 @@ impl PasswordHasher<PasswordHash> for Pbkdf2 {
6875
}
6976
}
7077

78+
// Base64 support: PBKDF2 uses a variant of standard unpadded Base64 which substitutes the `+`
79+
// character for `.` and this is a distinct encoding from the bcrypt and crypt Base64 variants.
80+
81+
#[cfg(test)]
82+
fn base64_decode(base64: &str) -> Result<Vec<u8>> {
83+
Base64::B64
84+
.decode_vec(&base64.replace('.', "+"))
85+
.map_err(|_| Error::EncodingInvalid)
86+
}
87+
88+
fn base64_encode(bytes: &[u8]) -> String {
89+
Base64::B64.encode_string(bytes).replace('+', ".")
90+
}
91+
7192
// TODO(tarcieri): tests for SHA-1 and SHA-512
7293
#[cfg(test)]
7394
mod tests {
74-
use super::PBKDF2_BASE64;
95+
use super::base64_decode;
7596
use crate::{Params, Pbkdf2};
7697
use mcf::PasswordHash;
7798
use password_hash::CustomizedPasswordHasher;
7899

79100
// Example adapted from:
80101
// <https://passlib.readthedocs.io/en/stable/lib/passlib.hash.pbkdf2_digest.html>
81-
82-
const EXAMPLE_PASSWORD: &[u8] = b"password";
83-
const EXAMPLE_ROUNDS: u32 = 8000;
84-
const EXAMPLE_SALT: &str = "XAuBMIYQQogxRg";
85-
const EXAMPLE_HASH: &str =
86-
"$pbkdf2-sha256$8000$XAuBMIYQQogxRg$tRRlz8hYn63B9LYiCd6PRo6FMiunY9ozmMMI3srxeRE";
87-
88102
#[test]
89103
fn hash_password_sha256() {
90-
let salt = PBKDF2_BASE64.decode_vec(EXAMPLE_SALT).unwrap();
104+
const EXAMPLE_PASSWORD: &[u8] = b"password";
105+
const EXAMPLE_ROUNDS: u32 = 8000;
106+
const EXAMPLE_SALT: &str = "XAuBMIYQQogxRg";
107+
const EXAMPLE_HASH: &str =
108+
"$pbkdf2-sha256$8000$XAuBMIYQQogxRg$tRRlz8hYn63B9LYiCd6PRo6FMiunY9ozmMMI3srxeRE";
109+
110+
let salt = base64_decode(EXAMPLE_SALT).unwrap();
91111
let params = Params::new(EXAMPLE_ROUNDS);
92112

93113
let actual_hash: PasswordHash = Pbkdf2::default()
@@ -97,4 +117,24 @@ mod tests {
97117
let expected_hash = PasswordHash::new(EXAMPLE_HASH).unwrap();
98118
assert_eq!(expected_hash, actual_hash);
99119
}
120+
121+
// Example adapted from:
122+
// <https://github.com/hlandau/passlib/blob/8f820e0/hash/pbkdf2/pbkdf2_test.go>
123+
#[test]
124+
fn hash_password_sha512() {
125+
const EXAMPLE_PASSWORD: &[u8] = b"abcdefghijklmnop";
126+
const EXAMPLE_ROUNDS: u32 = 25000;
127+
const EXAMPLE_SALT: &str = "O4fwPmdMyRmDUIrx/h9jTA";
128+
const EXAMPLE_HASH: &str = "$pbkdf2-sha512$25000$O4fwPmdMyRmDUIrx/h9jTA$Xlp267ZwEbG4aOpN3Bve/ATo3rFA7WH8iMdS16Xbe9rc6P5welk1yiXEMPy7.BFp0qsncipHumaW1trCWVvq/A";
129+
130+
let salt = base64_decode(EXAMPLE_SALT).unwrap();
131+
let params = Params::new_with_output_len(EXAMPLE_ROUNDS, 64);
132+
133+
let actual_hash: PasswordHash = Pbkdf2::SHA512
134+
.hash_password_with_params(EXAMPLE_PASSWORD, salt.as_slice(), params)
135+
.unwrap();
136+
137+
let expected_hash = PasswordHash::new(EXAMPLE_HASH).unwrap();
138+
assert_eq!(expected_hash, actual_hash);
139+
}
100140
}

pbkdf2/src/params.rs

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,10 @@ use password_hash::{
1414
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
1515
pub struct Params {
1616
/// Number of rounds
17-
pub rounds: u32,
17+
rounds: u32,
1818

1919
/// Size of the output (in bytes)
20-
pub output_length: usize,
20+
output_len: usize,
2121
}
2222

2323
impl Params {
@@ -41,15 +41,33 @@ impl Params {
4141
/// [OWASP cheat sheet]: https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html
4242
pub const RECOMMENDED: Self = Params {
4343
rounds: Self::RECOMMENDED_ROUNDS,
44-
output_length: Self::RECOMMENDED_LENGTH,
44+
output_len: Self::RECOMMENDED_LENGTH,
4545
};
4646

4747
/// Create new params with the given number of rounds.
48-
pub fn new(rounds: u32) -> Self {
48+
pub const fn new(rounds: u32) -> Self {
4949
let mut ret = Self::RECOMMENDED;
5050
ret.rounds = rounds;
5151
ret
5252
}
53+
54+
/// Create new params with a customized output length.
55+
pub const fn new_with_output_len(rounds: u32, output_length: usize) -> Self {
56+
Self {
57+
rounds,
58+
output_len: output_length,
59+
}
60+
}
61+
62+
/// Get the number of rounds.
63+
pub const fn rounds(self) -> u32 {
64+
self.rounds
65+
}
66+
67+
/// Get the output length.
68+
pub const fn output_len(self) -> usize {
69+
self.output_len
70+
}
5371
}
5472

5573
impl Default for Params {
@@ -87,17 +105,17 @@ impl TryFrom<&ParamsString> for Params {
87105
.map_err(|_| Error::ParamInvalid { name: "i" })?
88106
}
89107
"l" => {
90-
let output_length = value
108+
let len = value
91109
.decimal()
92110
.ok()
93111
.and_then(|dec| dec.try_into().ok())
94112
.ok_or(Error::ParamInvalid { name: "l" })?;
95113

96-
if output_length > Self::MAX_LENGTH {
114+
if len > Self::MAX_LENGTH {
97115
return Err(Error::ParamInvalid { name: "l" });
98116
}
99117

100-
params.output_length = output_length;
118+
params.output_len = len;
101119
}
102120
_ => return Err(Error::ParamsInvalid),
103121
}
@@ -119,7 +137,7 @@ impl TryFrom<&phc::PasswordHash> for Params {
119137
let params = Self::try_from(&hash.params)?;
120138

121139
if let Some(hash) = &hash.hash {
122-
if hash.len() != params.output_length {
140+
if hash.len() != params.output_len {
123141
return Err(Error::OutputSize);
124142
}
125143
}
@@ -144,7 +162,7 @@ impl TryFrom<&Params> for ParamsString {
144162
fn try_from(input: &Params) -> password_hash::Result<ParamsString> {
145163
let mut output = ParamsString::new();
146164

147-
for (name, value) in [("i", input.rounds), ("l", input.output_length as Decimal)] {
165+
for (name, value) in [("i", input.rounds), ("l", input.output_len as Decimal)] {
148166
output
149167
.add_decimal(name, value)
150168
.map_err(|_| Error::ParamInvalid { name })?;

pbkdf2/src/phc.rs

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ impl CustomizedPasswordHasher<PasswordHash> for Pbkdf2 {
3737

3838
let mut buffer = [0u8; Params::MAX_LENGTH];
3939
let out = buffer
40-
.get_mut(..params.output_length)
40+
.get_mut(..params.output_len())
4141
.ok_or(Error::OutputSize)?;
4242

4343
let f = match algorithm {
@@ -47,7 +47,7 @@ impl CustomizedPasswordHasher<PasswordHash> for Pbkdf2 {
4747
Algorithm::Pbkdf2Sha512 => pbkdf2_hmac::<Sha512>,
4848
};
4949

50-
f(password, &salt, params.rounds, out);
50+
f(password, &salt, params.rounds(), out);
5151
let output = Output::new(out)?;
5252

5353
Ok(PasswordHash {
@@ -88,10 +88,7 @@ mod tests {
8888
/// dkLen = 40
8989
#[test]
9090
fn hash_with_default_algorithm() {
91-
let params = Params {
92-
rounds: 4096,
93-
output_length: 40,
94-
};
91+
let params = Params::new_with_output_len(4096, 40);
9592

9693
let pwhash: PasswordHash = Pbkdf2::default()
9794
.hash_password_customized(PASSWORD, SALT, None, None, params)

0 commit comments

Comments
 (0)