diff --git a/.readme/Cargo.toml b/.readme/Cargo.toml index 3fe88d6e..6907078e 100644 --- a/.readme/Cargo.toml +++ b/.readme/Cargo.toml @@ -9,4 +9,4 @@ publish = false password-hash = "0.6.0-rc.3" argon2 = { path = "../argon2" } pbkdf2 = { path = "../pbkdf2", features = ["password-hash"] } -scrypt = { path = "../scrypt" } +scrypt = { path = "../scrypt", features = ["phc"] } diff --git a/Cargo.lock b/Cargo.lock index b681e902..7bc970c3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -418,11 +418,13 @@ dependencies = [ name = "scrypt" version = "0.12.0-rc.6" dependencies = [ + "mcf", "password-hash", "pbkdf2", "rayon", "salsa20", "sha2", + "subtle", ] [[package]] diff --git a/password-auth/Cargo.toml b/password-auth/Cargo.toml index beed6c23..56df5226 100644 --- a/password-auth/Cargo.toml +++ b/password-auth/Cargo.toml @@ -23,7 +23,7 @@ password-hash = { version = "0.6.0-rc.6", features = ["alloc", "getrandom", "phc # optional dependencies argon2 = { version = "0.6.0-rc.5", optional = true, default-features = false, features = ["alloc", "password-hash"] } pbkdf2 = { version = "0.13.0-rc.5", optional = true, default-features = false, features = ["password-hash"] } -scrypt = { version = "0.12.0-rc.6", optional = true, default-features = false, features = ["password-hash"] } +scrypt = { version = "0.12.0-rc.6", optional = true, default-features = false, features = ["phc"] } [features] default = ["argon2", "std"] diff --git a/password-auth/src/lib.rs b/password-auth/src/lib.rs index 564ec6da..aec59fb2 100644 --- a/password-auth/src/lib.rs +++ b/password-auth/src/lib.rs @@ -118,7 +118,7 @@ pub fn is_hash_obsolete(hash: &str) -> Result { || hash.params != default_params_string::()); #[cfg(feature = "scrypt")] - return Ok(hash.algorithm != scrypt::ALG_ID + return Ok(hash.algorithm != scrypt::phc::ALG_ID || hash.params != default_params_string::()); #[cfg(feature = "pbkdf2")] diff --git a/scrypt/Cargo.toml b/scrypt/Cargo.toml index 5247de65..30da243b 100644 --- a/scrypt/Cargo.toml +++ b/scrypt/Cargo.toml @@ -20,14 +20,16 @@ sha2 = { version = "0.11.0-rc.3", default-features = false } rayon = { version = "1.11", optional = true } # optional dependencies -password-hash = { version = "0.6.0-rc.6", optional = true, default-features = false, features = ["phc"] } +mcf = { version = "0.6.0-rc.0", optional = true } +password-hash = { version = "0.6.0-rc.6", optional = true, default-features = false } +subtle = { version = "2", optional = true, default-features = false } [features] -default = ["password-hash", "rayon"] alloc = ["password-hash?/alloc"] getrandom = ["password-hash", "password-hash/getrandom"] -password-hash = ["dep:password-hash"] +mcf = ["alloc", "password-hash", "dep:mcf", "dep:subtle"] +phc = ["password-hash/phc"] rand_core = ["password-hash/rand_core"] rayon = ["dep:rayon"] diff --git a/scrypt/src/lib.rs b/scrypt/src/lib.rs index 01e39ee4..9ff2a117 100644 --- a/scrypt/src/lib.rs +++ b/scrypt/src/lib.rs @@ -27,10 +27,11 @@ //! let password = b"hunter42"; // Bad password; don't actually use! //! //! // Hash password to PHC string ($scrypt$...) -//! let password_hash = Scrypt.hash_password(password)?.to_string(); +//! let hash: PasswordHash = Scrypt.hash_password(password)?; +//! let hash_string = hash.to_string(); //! //! // Verify password against PHC string -//! let parsed_hash = PasswordHash::new(&password_hash)?; +//! let parsed_hash = PasswordHash::new(&hash_string)?; //! assert!(Scrypt.verify_password(password, &parsed_hash).is_ok()); //! # Ok(()) //! # } @@ -58,16 +59,18 @@ pub mod errors; mod params; mod romix; -#[cfg(feature = "password-hash")] -mod phc; +#[cfg(feature = "mcf")] +pub mod mcf; +#[cfg(feature = "phc")] +pub mod phc; pub use crate::params::Params; #[cfg(feature = "password-hash")] pub use password_hash; -#[cfg(feature = "password-hash")] -pub use crate::phc::{ALG_ID, Scrypt}; +#[cfg(all(doc, feature = "password-hash"))] +use password_hash::{CustomizedPasswordHasher, PasswordHasher, PasswordVerifier}; /// The scrypt key derivation function. /// @@ -139,3 +142,14 @@ fn romix_parallel(nr128: usize, r128: usize, n: usize, b: &mut [u8]) { romix::scrypt_ro_mix(chunk, &mut v, &mut t, n); }); } + +/// scrypt password hashing type which can produce and verify strings in either the Password Hashing +/// Competition (PHC) string format which begin with `$scrypt$`, or in Modular Crypt Format (MCF) +/// which begin with `$7$`. +/// +/// This is a ZST which impls traits from the [`password-hash`][`password_hash`] crate, notably +/// the [`PasswordHasher`], [`PasswordVerifier`], and [`CustomizedPasswordHasher`] traits. +/// +/// See the toplevel documentation for a code example. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub struct Scrypt; diff --git a/scrypt/src/mcf.rs b/scrypt/src/mcf.rs new file mode 100644 index 00000000..2621f113 --- /dev/null +++ b/scrypt/src/mcf.rs @@ -0,0 +1,294 @@ +//! Implementation of the `password-hash` traits for Modular Crypt Format (MCF) password hash +//! strings which begin with `$7$`: +//! +//! + +pub use mcf::{PasswordHash, PasswordHashRef}; + +use crate::{Params, Scrypt, scrypt}; +use alloc::{string::String, vec}; +use core::str; +use mcf::Base64; +use password_hash::{ + CustomizedPasswordHasher, Error, PasswordHasher, PasswordVerifier, Result, Version, +}; + +/// Identifier for scrypt when encoding to the Modular Crypt Format, i.e. `$7$` +#[cfg(feature = "password-hash")] +const MCF_ID: &str = "7"; + +/// Base64 variant used by scrypt. +const SCRYPT_BASE64: Base64 = Base64::ShaCrypt; + +/// Size of a `u32` when using scrypt's fixed-width Base64 encoding. +const ENCODED_U32_LEN: usize = 5; + +/// Length of scrypt's params when encoded as binary: `log_n`: 1-byte, `r`/`p`: 5-bytes +const PARAMS_LEN: usize = 1 + (2 * ENCODED_U32_LEN); + +impl CustomizedPasswordHasher for Scrypt { + type Params = Params; + + fn hash_password_customized( + &self, + password: &[u8], + salt: &[u8], + alg_id: Option<&str>, + version: Option, + params: Params, + ) -> Result { + // TODO(tarcieri): tunable hash output size? + const HASH_SIZE: usize = 32; + + match alg_id { + Some(MCF_ID) | None => (), + _ => return Err(Error::Algorithm), + } + + if version.is_some() { + return Err(Error::Version); + } + + let params_and_salt = encode_params_and_salt(params, salt)?; + + // When used with MCF, the scrypt salt is Base64 encoded + let salt = ¶ms_and_salt.as_bytes()[PARAMS_LEN..]; + + let mut out = [0u8; HASH_SIZE]; + scrypt(password, salt, ¶ms, &mut out).map_err(|_| Error::OutputSize)?; + + // Begin building the Modular Crypt Format hash. + let mut mcf_hash = PasswordHash::from_id(MCF_ID).expect("should be valid"); + + // Add salt + mcf_hash + .push_str(¶ms_and_salt) + .map_err(|_| Error::EncodingInvalid)?; + + // Add scrypt password hashing function output + mcf_hash.push_base64(&out, SCRYPT_BASE64); + + Ok(mcf_hash) + } +} + +impl PasswordHasher for Scrypt { + fn hash_password_with_salt(&self, password: &[u8], salt: &[u8]) -> Result { + self.hash_password_customized(password, salt, None, None, Params::RECOMMENDED) + } +} + +impl PasswordVerifier for Scrypt { + fn verify_password(&self, password: &[u8], hash: &PasswordHash) -> Result<()> { + self.verify_password(password, hash.as_password_hash_ref()) + } +} + +impl PasswordVerifier for Scrypt { + fn verify_password(&self, password: &[u8], hash: &PasswordHashRef) -> Result<()> { + // verify id matches `$7` + if hash.id() != MCF_ID { + return Err(Error::Algorithm); + } + + let mut fields = hash.fields(); + + // decode params and salt + let (params, salt) = + decode_params_and_salt(fields.next().ok_or(Error::EncodingInvalid)?.as_str())?; + + // decode expected password hash + let expected = fields + .next() + .ok_or(Error::EncodingInvalid)? + .decode_base64(SCRYPT_BASE64) + .map_err(|_| Error::EncodingInvalid)?; + + // should be the last field + if fields.next().is_some() { + return Err(Error::EncodingInvalid); + } + + let mut actual = vec![0u8; expected.len()]; + scrypt(password, salt, ¶ms, &mut actual).map_err(|_| Error::OutputSize)?; + + if subtle::ConstantTimeEq::ct_ne(actual.as_slice(), &expected).into() { + return Err(Error::PasswordInvalid); + } + + Ok(()) + } +} + +/// scrypt-flavored Base64 alphabet. +static ITOA64: &[u8] = b"./0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; + +/// Reverse lookup table for scrypt-flavored Base64 alphabet. +static ATOI64: [u8; 128] = { + let mut tbl = [0xFFu8; 128]; // use 0xFF as a placeholder for invalid chars + let mut i = 0u8; + while i < 64 { + tbl[ITOA64[i as usize] as usize] = i; + i += 1; + } + tbl +}; + +/// Decode scrypt parameters and salt from the combined string they're encoded in. +fn decode_params_and_salt(s: &str) -> Result<(Params, &[u8])> { + let bytes = s.as_bytes(); + + if bytes.is_empty() { + return Err(Error::EncodingInvalid); + } + + // log_n + let log_n = *ATOI64 + .get(bytes[0] as usize) + .ok_or(Error::EncodingInvalid)?; + + let mut pos = 1; + + // r + let r = decode64_uint32(&bytes[pos..])?; + pos += ENCODED_U32_LEN; + + // p + let p = decode64_uint32(&bytes[pos..])?; + pos += ENCODED_U32_LEN; + + let params = Params::new(log_n, r, p).map_err(|_| Error::ParamsInvalid)?; + + Ok((params, &s.as_bytes()[pos..])) +} + +/// Encode scrypt parameters and salt into scrypt-flavored Base64. +fn encode_params_and_salt(params: Params, salt: &[u8]) -> Result { + let mut buf = [0u8; PARAMS_LEN]; + let params_base64 = encode_params(params, &mut buf)?; + + let mut ret = String::from(params_base64); + ret.push_str(&SCRYPT_BASE64.encode_string(salt)); + Ok(ret) +} + +/// Encode params as scrypt-flavored Base64 to the given output buffer. +fn encode_params(params: Params, out: &mut [u8]) -> Result<&str> { + // encode log_n (uses a special 1-byte encoding) + let encoded_log_n = *ITOA64 + .get(params.log_n as usize) + .ok_or(Error::EncodingInvalid)?; + + *out.get_mut(0).ok_or(Error::EncodingInvalid)? = encoded_log_n; + + let mut pos = 1; + + // encode r + encode64_uint32(&mut out[pos..], params.r())?; + pos += ENCODED_U32_LEN; + + // encode p + encode64_uint32(&mut out[pos..], params.p())?; + pos += ENCODED_U32_LEN; + + str::from_utf8(&out[..pos]).map_err(|_| Error::EncodingInvalid) +} + +/// Decode 32-bit integer value from Base64. +/// +/// Uses a fixed-width little endian encoding. +fn decode64_uint32(src: &[u8]) -> Result { + let mut value: u32 = 0; + + for i in 0..ENCODED_U32_LEN { + let n = *src + .get(i) + .and_then(|&b| ATOI64.get(b as usize).filter(|&&c| c != 0xFF)) + .ok_or(Error::EncodingInvalid)?; + + value |= (n as u32) << (6 * i); + } + + Ok(value) +} + +/// Encode 32-bit integer value from Base64. +/// +/// Uses a fixed-width little endian encoding. +fn encode64_uint32(dst: &mut [u8], mut src: u32) -> Result<()> { + if dst.len() < 5 { + return Err(Error::EncodingInvalid); + } + + #[allow(clippy::needless_range_loop)] + for i in 0..ENCODED_U32_LEN { + dst[i] = ITOA64[(src & 0x3f) as usize]; + src >>= 6; + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::{ + CustomizedPasswordHasher, Error, Params, PasswordHash, PasswordHashRef, PasswordVerifier, + SCRYPT_BASE64, Scrypt, decode_params_and_salt, + }; + + /// Password used to make the example MCF hash. + const EXAMPLE_PASSWORD: &[u8] = b"pleaseletmein"; + + /// Salt used to generate the hash, encoded as Base64. + const EXAMPLE_SALT: &str = "Mq4YHD2syxYT.MsH1Ek0n1"; + + /// Generated using `mkpasswd --method=scrypt` + const EXAMPLE_MCF_HASH: &str = + "$7$CU..../....Mq4YHD2syxYT.MsH1Ek0n1$JyHIxez0DOwm0r6.kAIohc8UFBOLU4xX8a1wGBpLrw7"; + + // libxcrypt defaults: https://github.com/besser82/libxcrypt/blob/a74a677/lib/crypt-scrypt.c#L213-L215 + // TODO(tarcieri): const constructor for `Params` + const EXAMPLE_LOG_N: u8 = 14; // count = 7; count + 7 (L215) + const EXAMPLE_R: u32 = 32; // uint32_t r = 32; (L214) + const EXAMPLE_P: u32 = 1; // uint32_t p = 1; (L213) + + #[test] + fn params_and_salt_decoder() { + let mut mcf_iter = EXAMPLE_MCF_HASH.split('$'); + mcf_iter.next().unwrap(); + mcf_iter.next().unwrap(); + + let params_and_salt = mcf_iter.next().unwrap(); + let (params, salt) = decode_params_and_salt(params_and_salt).unwrap(); + + assert_eq!(params.p(), EXAMPLE_P); + assert_eq!(params.r(), EXAMPLE_R); + assert_eq!(params.log_n(), EXAMPLE_LOG_N); + + assert_eq!(salt, EXAMPLE_SALT.as_bytes()); + } + + #[test] + fn hash_password() { + let salt = SCRYPT_BASE64.decode_vec(EXAMPLE_SALT).unwrap(); + let params = Params::new(EXAMPLE_LOG_N, EXAMPLE_R, EXAMPLE_P).unwrap(); + + let actual_hash: PasswordHash = Scrypt + .hash_password_with_params(EXAMPLE_PASSWORD, &salt, params) + .unwrap(); + + let expected_hash = PasswordHash::new(EXAMPLE_MCF_HASH).unwrap(); + assert_eq!(expected_hash, actual_hash); + } + + #[test] + fn verify_password() { + let hash = PasswordHashRef::new(EXAMPLE_MCF_HASH).unwrap(); + assert_eq!(Scrypt.verify_password(EXAMPLE_PASSWORD, hash), Ok(())); + + assert_eq!( + Scrypt.verify_password(b"bogus", hash), + Err(Error::PasswordInvalid) + ); + } +} diff --git a/scrypt/src/params.rs b/scrypt/src/params.rs index 50420c4d..79e55d40 100644 --- a/scrypt/src/params.rs +++ b/scrypt/src/params.rs @@ -1,6 +1,6 @@ use crate::errors::InvalidParams; -#[cfg(feature = "password-hash")] +#[cfg(feature = "phc")] use { core::{ fmt::{self, Display}, @@ -12,7 +12,7 @@ use { }, }; -#[cfg(all(feature = "password-hash", doc))] +#[cfg(all(feature = "phc", doc))] use password_hash::PasswordHasher; /// The Scrypt parameter values. @@ -118,7 +118,7 @@ impl Params { /// The allowed values for `len` are between 10 bytes (80 bits) and 64 bytes inclusive. /// These lengths come from the [PHC string format specification](https://github.com/P-H-C/phc-string-format/blob/master/phc-sf-spec.md) /// because they are intended for use with password hash strings. - #[cfg(feature = "password-hash")] + #[cfg(feature = "phc")] pub fn new_with_output_len( log_n: u8, r: u32, @@ -176,14 +176,14 @@ impl Default for Params { } } -#[cfg(feature = "password-hash")] +#[cfg(feature = "phc")] impl Display for Params { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { ParamsString::try_from(self).map_err(|_| fmt::Error)?.fmt(f) } } -#[cfg(feature = "password-hash")] +#[cfg(feature = "phc")] impl FromStr for Params { type Err = Error; @@ -193,7 +193,7 @@ impl FromStr for Params { } } -#[cfg(feature = "password-hash")] +#[cfg(feature = "phc")] impl TryFrom<&ParamsString> for Params { type Error = Error; @@ -229,7 +229,7 @@ impl TryFrom<&ParamsString> for Params { } } -#[cfg(feature = "password-hash")] +#[cfg(feature = "phc")] impl TryFrom<&PasswordHash> for Params { type Error = Error; @@ -250,7 +250,7 @@ impl TryFrom<&PasswordHash> for Params { } } -#[cfg(feature = "password-hash")] +#[cfg(feature = "phc")] impl TryFrom for ParamsString { type Error = Error; @@ -259,7 +259,7 @@ impl TryFrom for ParamsString { } } -#[cfg(feature = "password-hash")] +#[cfg(feature = "phc")] impl TryFrom<&Params> for ParamsString { type Error = Error; diff --git a/scrypt/src/phc.rs b/scrypt/src/phc.rs index d0fc5b63..90460e77 100644 --- a/scrypt/src/phc.rs +++ b/scrypt/src/phc.rs @@ -1,10 +1,9 @@ //! Implementation of the `password-hash` crate API. -use crate::{Params, scrypt}; -use password_hash::{ - CustomizedPasswordHasher, Error, PasswordHasher, Result, Version, - phc::{Ident, Output, PasswordHash, Salt}, -}; +pub use password_hash::phc::{Ident, Output, PasswordHash, Salt}; + +use crate::{Params, Scrypt, scrypt}; +use password_hash::{CustomizedPasswordHasher, Error, PasswordHasher, Result, Version}; /// Algorithm name const ALG_NAME: &str = "scrypt"; @@ -12,12 +11,6 @@ const ALG_NAME: &str = "scrypt"; /// Algorithm identifier pub const ALG_ID: Ident = Ident::new_unwrap(ALG_NAME); -/// scrypt type for use with [`PasswordHasher`]. -/// -/// See the [crate docs](crate) for a usage example. -#[derive(Copy, Clone, Debug, Eq, PartialEq)] -pub struct Scrypt; - impl CustomizedPasswordHasher for Scrypt { type Params = Params; @@ -62,3 +55,30 @@ impl PasswordHasher for Scrypt { self.hash_password_customized(password, salt, None, None, Params::default()) } } + +#[cfg(test)] +mod tests { + use super::{PasswordHash, Scrypt}; + use password_hash::PasswordVerifier; + + /// Test vector from passlib: + /// + #[cfg(feature = "password-hash")] + const EXAMPLE_PASSWORD_HASH: &str = + "$scrypt$ln=16,r=8,p=1$aM15713r3Xsvxbi31lqr1Q$nFNh2CVHVjNldFVKDHDlm4CbdRSCdEBsjjJxD+iCs5E"; + + #[cfg(feature = "password-hash")] + #[test] + fn password_hash_verify_password() { + let password = "password"; + let hash = PasswordHash::new(EXAMPLE_PASSWORD_HASH).unwrap(); + assert_eq!(Scrypt.verify_password(password.as_bytes(), &hash), Ok(())); + } + + #[cfg(feature = "password-hash")] + #[test] + fn password_hash_reject_incorrect_password() { + let hash = PasswordHash::new(EXAMPLE_PASSWORD_HASH).unwrap(); + assert!(Scrypt.verify_password(b"invalid", &hash).is_err()); + } +} diff --git a/scrypt/tests/mod.rs b/scrypt/tests/mod.rs index 951f6fef..3cbb6792 100644 --- a/scrypt/tests/mod.rs +++ b/scrypt/tests/mod.rs @@ -1,11 +1,5 @@ use scrypt::{Params, scrypt}; -#[cfg(feature = "password-hash")] -use { - password_hash::{PasswordVerifier, phc::PasswordHash}, - scrypt::Scrypt, -}; - struct Test { password: &'static str, salt: &'static str, @@ -80,24 +74,3 @@ fn test_scrypt() { assert!(result == t.expected); } } - -/// Test vector from passlib: -/// -#[cfg(feature = "password-hash")] -const EXAMPLE_PASSWORD_HASH: &str = - "$scrypt$ln=16,r=8,p=1$aM15713r3Xsvxbi31lqr1Q$nFNh2CVHVjNldFVKDHDlm4CbdRSCdEBsjjJxD+iCs5E"; - -#[cfg(feature = "password-hash")] -#[test] -fn password_hash_verify_password() { - let password = "password"; - let hash = PasswordHash::new(EXAMPLE_PASSWORD_HASH).unwrap(); - assert_eq!(Scrypt.verify_password(password.as_bytes(), &hash), Ok(())); -} - -#[cfg(feature = "password-hash")] -#[test] -fn password_hash_reject_incorrect_password() { - let hash = PasswordHash::new(EXAMPLE_PASSWORD_HASH).unwrap(); - assert!(Scrypt.verify_password(b"invalid", &hash).is_err()); -} diff --git a/yescrypt/tests/mcf.rs b/yescrypt/tests/mcf.rs index 7ffee85f..63d835fb 100644 --- a/yescrypt/tests/mcf.rs +++ b/yescrypt/tests/mcf.rs @@ -77,13 +77,11 @@ fn compute_reference_strings() { fn verify_reference_strings() { for &hash in EXAMPLE_HASHES { let hash = PasswordHashRef::new(hash).unwrap(); + assert_eq!(Yescrypt.verify_password(EXAMPLE_PASSWD, hash), Ok(())); - if Yescrypt.verify_password(EXAMPLE_PASSWD, hash).is_err() { - panic!("failed to verify password hash: {hash}"); - } - - if Yescrypt.verify_password(b"bogus", hash) != Err(Error::PasswordInvalid) { - panic!("verification unexpectedly succeeded for password hash: {hash}"); - } + assert_eq!( + Yescrypt.verify_password(b"bogus", hash), + Err(Error::PasswordInvalid) + ); } }