diff --git a/CHANGELOG.md b/CHANGELOG.md index 53cffd96..12c3de9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,12 @@ uses of the following methods. Specify the numbers iterations when calling the m if your code depends on the iterations to stay the same : `derive_key_pbkdf2`, `deriveKeyPbkdf2`, `DeriveKeyPbkdf2`, `EncryptWithPassword`, `EncryptWithPasswordAsBase64String`, `DecryptWithPassword`, `DecryptWithPasswordAsUtf8String`, `DeriveKey` +- `hash_password` no longer accepts an `iterations` parameter. The function signature is now `hash_password(password, version)`. Callers that previously passed a custom iteration count must switch to the new `hash_password_with_parameters` / `HashPasswordWithParams` / `hash_password_with_params` APIs and supply explicit `DerivationParameters`. + +### Added + +- **`PasswordHashVersion::V2`**: new Argon2id-based password hashing using OWASP-recommended defaults (memory = 64 MiB, iterations = 3). This is now the default (`PasswordHashVersion::Latest`). + ### Changed - Multiple functions, such as `generate_key` and `hash_password`, now return a `Result` due to the `rand` library upgrade. diff --git a/README_RUST.md b/README_RUST.md index 5c52fd3c..c45fcf3b 100644 --- a/README_RUST.md +++ b/README_RUST.md @@ -173,8 +173,7 @@ You can use this module to hash a password and validate it afterward. This is th use devolutions_crypto::password_hash::{hash_password, PasswordHashVersion}; let password = b"somesuperstrongpa$$w0rd!"; - -let hashed_password = hash_password(password, 600000, PasswordHashVersion::Latest); +let hashed_password = hash_password(password, PasswordHashVersion::Latest).unwrap(); assert!(hashed_password.verify_password(b"somesuperstrongpa$$w0rd!")); assert!(!hashed_password.verify_password(b"someweakpa$$w0rd!")); diff --git a/cli/src/main.rs b/cli/src/main.rs index b49a0e56..c3e24516 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -125,9 +125,10 @@ enum Commands { /// The password to hash password: String, - /// The number of iteration for the derivation algorithm + /// Serialized DerivationParameters in base64. When omitted, uses the latest default + /// algorithm (Argon2id with OWASP-recommended parameters). #[arg(short, long)] - iterations: Option, + params: Option, }, /// Verify a password from its hash @@ -193,10 +194,7 @@ fn main() { } Commands::Decrypt { data, key } => decrypt(data, key), Commands::DecryptAsymmetric { data, key } => decrypt_asymmetric(data, key), - Commands::HashPassword { - password, - iterations, - } => hash_password(password, iterations), + Commands::HashPassword { password, params } => hash_password(password, params), Commands::VerifyPassword { password, hash } => verify_password(hash, password), Commands::MixKeyExchange { private, public } => mix_key_exchange(private, public), Commands::JoinShares { shares } => join_shares(shares), @@ -401,14 +399,27 @@ fn decrypt_asymmetric(data: String, key: String) { println!("{}", String::from_utf8_lossy(&result)); } -fn hash_password(password: String, iterations: Option) { - let iterations = iterations.unwrap_or(DEFAULT_PBKDF2_ITERATIONS); - - let hash: Vec = devolutions_crypto::password_hash::hash_password( - &password.as_bytes(), - iterations, - Default::default(), - ) +fn hash_password(password: String, params: Option) { + let hash: Vec = match params { + Some(p) => { + let params_bytes = decode_base64_arg("--params", &p); + let params = devolutions_crypto::key_derivation::DerivationParameters::try_from( + params_bytes.as_slice(), + ) + .unwrap_or_else(|e| { + eprintln!("Error: '--params' - invalid DerivationParameters: {}.", e); + std::process::exit(1); + }); + devolutions_crypto::password_hash::hash_password_with_parameters( + password.as_bytes(), + params, + ) + } + None => devolutions_crypto::password_hash::hash_password( + password.as_bytes(), + Default::default(), + ), + } .unwrap() .into(); println!("{}", base64::encode(&hash)); diff --git a/ffi/src/lib.rs b/ffi/src/lib.rs index a535f529..4b42f936 100644 --- a/ffi/src/lib.rs +++ b/ffi/src/lib.rs @@ -24,8 +24,10 @@ use devolutions_crypto::ciphertext::{ use devolutions_crypto::key::{ generate_keypair, generate_secret_key, mix_key_exchange, KeyVersion, PrivateKey, PublicKey, }; -use devolutions_crypto::key_derivation::{Argon2, Pbkdf2}; -use devolutions_crypto::password_hash::{hash_password, PasswordHash, PasswordHashVersion}; +use devolutions_crypto::key_derivation::{Argon2, DerivationParameters, Pbkdf2}; +use devolutions_crypto::password_hash::{ + hash_password, hash_password_with_parameters, PasswordHash, PasswordHashVersion, +}; use devolutions_crypto::secret_sharing::{ generate_shared_key, join_shares, SecretSharingVersion, Share, }; @@ -42,9 +44,6 @@ use devolutions_crypto::{ use devolutions_crypto::Result; -#[cfg(test)] -use devolutions_crypto::key_derivation::DerivationParameters; - use std::borrow::Borrow; use std::ffi::c_void; use std::slice; @@ -459,8 +458,6 @@ pub extern "C" fn SignSize(_version: u16) -> i64 { /// # Arguments /// * `password` - Pointer to the password to hash. /// * `password_length` - Length of the password to hash. -/// * `iterations` - Number of iterations of the password hash. -/// A higher number is slower but harder to brute-force. The recommended value is 600000. /// * `result` - Pointer to the buffer to write the hash to. /// * `result_length` - Length of the buffer to write the hash to. You can get the value by /// calling HashPasswordLength() beforehand. @@ -473,7 +470,6 @@ pub extern "C" fn SignSize(_version: u16) -> i64 { pub unsafe extern "C" fn HashPassword( password: *const u8, password_length: usize, - iterations: u32, result: *mut u8, result_length: usize, ) -> i64 { @@ -488,23 +484,105 @@ pub unsafe extern "C" fn HashPassword( let password = slice::from_raw_parts(password, password_length); let result = slice::from_raw_parts_mut(result, result_length); - let res: Zeroizing> = - match hash_password(password, iterations, PasswordHashVersion::Latest) { - Ok(x) => Zeroizing::new(x.into()), - Err(e) => return e.error_code(), - }; + let res: Zeroizing> = match hash_password(password, PasswordHashVersion::Latest) { + Ok(x) => Zeroizing::new(x.into()), + Err(e) => return e.error_code(), + }; let length = res.len(); result[0..length].copy_from_slice(&res); length as i64 } -/// Get the size of the resulting hash. -/// # Returns /// Returns the length of the hash to input as `result_length` in `HashPassword()`. +/// The size reflects the default Argon2id parameters. +/// # Returns +/// Returns the length of the hash. #[no_mangle] pub extern "C" fn HashPasswordLength() -> i64 { - 8 + 4 + 32 + 32 // Header + iterations + salt + hash + // 8 (PasswordHash header) + // + 4 (u32 params_len) + // + 8 (DerivationParameters header) + // + GetDefaultArgon2ParametersSize() (Argon2Parameters default) + // + 32 (Argon2 default output length) + 8 + 4 + 8 + GetDefaultArgon2ParametersSize() + 32 +} + +/// Hash a password using caller-supplied serialized [`DerivationParameters`]. +/// +/// This allows full control over the hashing algorithm (Argon2id or PBKDF2) and +/// its parameters. Use `HashPasswordWithParamsLength()` to obtain the required output buffer size. +/// # Arguments +/// * `password` - Pointer to the password to hash. +/// * `password_length` - Length of the password. +/// * `params` - Pointer to the serialized `DerivationParameters` bytes. +/// * `params_length` - Length of the serialized `DerivationParameters`. +/// * `result` - Pointer to the output buffer. +/// * `result_length` - Length of the output buffer (use `HashPasswordWithParamsLength()`). +/// # Returns +/// Returns the number of bytes written, or a negative error code. +/// # Safety +/// This method is made to be called by C, so it is therefore unsafe. +#[no_mangle] +pub unsafe extern "C" fn HashPasswordWithParams( + password: *const u8, + password_length: usize, + params: *const u8, + params_length: usize, + result: *mut u8, + result_length: usize, +) -> i64 { + if password.is_null() || params.is_null() || result.is_null() { + return Error::NullPointer.error_code(); + }; + + let password = slice::from_raw_parts(password, password_length); + let params_slice = slice::from_raw_parts(params, params_length); + let result = slice::from_raw_parts_mut(result, result_length); + + let dp = match DerivationParameters::try_from(params_slice) { + Ok(p) => p, + Err(e) => return e.error_code(), + }; + + let expected_len = HashPasswordWithParamsLength(params, params_length) as usize; + if result_length != expected_len { + return Error::InvalidOutputLength.error_code(); + }; + + let res: Zeroizing> = match hash_password_with_parameters(password, dp) { + Ok(x) => Zeroizing::new(x.into()), + Err(e) => return e.error_code(), + }; + + let length = res.len(); + result[0..length].copy_from_slice(&res); + length as i64 +} + +/// Returns the output buffer size required for `HashPasswordWithParams()`. +/// # Arguments +/// * `params` - Pointer to the serialized `DerivationParameters` bytes. +/// * `params_length` - Length of the serialized `DerivationParameters`. +/// # Returns +/// Returns the required output length, or a negative error code. +/// # Safety +/// This method is made to be called by C, so it is therefore unsafe. +#[no_mangle] +pub unsafe extern "C" fn HashPasswordWithParamsLength( + params: *const u8, + params_length: usize, +) -> i64 { + if params.is_null() { + return Error::NullPointer.error_code(); + }; + let params_slice = slice::from_raw_parts(params, params_length); + let dp = match DerivationParameters::try_from(params_slice) { + Ok(p) => p, + Err(e) => return e.error_code(), + }; + // 8 (PasswordHash header) + 4 (u32 params_len) + params_length + hash_length + (8 + 4 + params_length + dp.output_length()) as i64 } /// Verify a password against a hash with constant-time equality. @@ -1619,6 +1697,97 @@ pub extern "C" fn DeriveSecretKeyArgon2ParametersSize(argon2_parameters_length: (8 + argon2_parameters_length) as i64 } +/// Returns the required output buffer size for `GetArgon2DerivationParameters()`. +/// The size is: 8 (header) + argon2_parameters_length (serialized Argon2Parameters bytes). +/// # Arguments +/// * argon2_parameters_length - The length of the Argon2Parameters bytes. +#[no_mangle] +pub extern "C" fn GetArgon2DerivationParametersSize(argon2_parameters_length: usize) -> i64 { + (8 + argon2_parameters_length) as i64 +} + +/// Build a serialized `DerivationParameters` from the given `Argon2Parameters` without +/// performing any key derivation. This is the low-cost counterpart to `DeriveSecretKeyArgon2()`. +/// # Arguments +/// * `argon2_parameters` - Pointer to the serialized `Argon2Parameters`. +/// * `argon2_parameters_length` - Length of the `Argon2Parameters` buffer. +/// * `result` - Pointer to the output buffer. +/// Must be `GetArgon2DerivationParametersSize(argon2_parameters_length)` bytes. +/// * `result_length` - Length of the output buffer. +/// # Returns +/// Returns the number of bytes written, or a negative error code. +/// # Safety +/// This method is made to be called by C, so it is therefore unsafe. +#[no_mangle] +pub unsafe extern "C" fn GetArgon2DerivationParameters( + argon2_parameters: *const u8, + argon2_parameters_length: usize, + result: *mut u8, + result_length: usize, +) -> i64 { + if argon2_parameters.is_null() || result.is_null() { + return Error::NullPointer.error_code(); + } + + if result_length != GetArgon2DerivationParametersSize(argon2_parameters_length) as usize { + return Error::InvalidOutputLength.error_code(); + } + + let argon2_parameters_raw = slice::from_raw_parts(argon2_parameters, argon2_parameters_length); + let argon2_params = match Argon2Parameters::try_from(argon2_parameters_raw) { + Ok(x) => x, + Err(e) => return e.error_code(), + }; + + let dp_bytes: Vec = Argon2::with_params(argon2_params).parameters().into(); + let result = slice::from_raw_parts_mut(result, result_length); + result.copy_from_slice(&dp_bytes); + result_length as i64 +} + +/// Returns the required output buffer size for `GetPbkdf2DerivationParameters()`. +/// The size is always 32 bytes: 8 (header) + 4 (iterations) + 4 (salt length) + 16 (salt). +#[no_mangle] +pub extern "C" fn GetPbkdf2DerivationParametersSize() -> i64 { + 32 // 8 header + 4 iterations + 4 salt_len + 16 salt +} + +/// Build a serialized `DerivationParameters` for PBKDF2 with the given iteration count, +/// without performing any key derivation. +/// # Arguments +/// * `iterations` - Number of PBKDF2 iterations. +/// * `result` - Pointer to the output buffer. +/// Must be `GetPbkdf2DerivationParametersSize()` bytes. +/// * `result_length` - Length of the output buffer. +/// # Returns +/// Returns the number of bytes written, or a negative error code. +/// # Safety +/// This method is made to be called by C, so it is therefore unsafe. +#[no_mangle] +pub unsafe extern "C" fn GetPbkdf2DerivationParameters( + iterations: u32, + result: *mut u8, + result_length: usize, +) -> i64 { + if result.is_null() { + return Error::NullPointer.error_code(); + } + + if result_length != GetPbkdf2DerivationParametersSize() as usize { + return Error::InvalidOutputLength.error_code(); + } + + let dp = match Pbkdf2::with_params(iterations).parameters() { + Ok(x) => x, + Err(e) => return e.error_code(), + }; + + let dp_bytes: Vec = dp.into(); + let result = slice::from_raw_parts_mut(result, result_length); + result.copy_from_slice(&dp_bytes); + result_length as i64 +} + /// # Arguments /// * `data` - Pointer to the input buffer. /// * `data_length` - Length of the input buffer. @@ -1960,14 +2129,12 @@ fn test_hash_password_length() { let long_password = b"this is a very long and complicated password that is, I hope,\ longer than the length of the actual hash. It also contains we1rd pa$$w0rd///s.\\"; - let small_password_hash: Vec = - hash_password(small_password, 100, PasswordHashVersion::Latest) - .unwrap() - .into(); - let long_password_hash: Vec = - hash_password(long_password, 2642, PasswordHashVersion::Latest) - .unwrap() - .into(); + let small_password_hash: Vec = hash_password(small_password, PasswordHashVersion::Latest) + .unwrap() + .into(); + let long_password_hash: Vec = hash_password(long_password, PasswordHashVersion::Latest) + .unwrap() + .into(); assert_eq!(HashPasswordLength() as usize, small_password_hash.len()); assert_eq!(HashPasswordLength() as usize, long_password_hash.len()); diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index 0b0c2d97..b9849a59 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -41,14 +41,6 @@ path = "fuzz_targets/ciphertext/decrypt_asymmetric.rs" name = "password_hash_deserialization" path = "fuzz_targets/password_hash/password_hash_deserialization.rs" -[[bin]] -name = "hash_password" -path = "fuzz_targets/password_hash/hash_password.rs" - -[[bin]] -name = "verify_password" -path = "fuzz_targets/password_hash/verify_password.rs" - [[bin]] name = "public_key_deserialization" path = "fuzz_targets/key/public_key_deserialization.rs" diff --git a/fuzz/fuzz_targets/password_hash/hash_password.rs b/fuzz/fuzz_targets/password_hash/hash_password.rs deleted file mode 100644 index aefc019e..00000000 --- a/fuzz/fuzz_targets/password_hash/hash_password.rs +++ /dev/null @@ -1,16 +0,0 @@ -#![no_main] -use arbitrary::Arbitrary; -use libfuzzer_sys::fuzz_target; - -use devolutions_crypto::password_hash::{hash_password, PasswordHashVersion}; - -#[derive(Arbitrary, Clone, Debug)] -struct Input { - data: Vec, - version: PasswordHashVersion, -} - -fuzz_target!(|data: Input| { - // Hardcode 2 iterations so it won't be too CPU demanding. - let _ = hash_password(&data.data, 2, data.version); -}); diff --git a/fuzz/fuzz_targets/password_hash/verify_password.rs b/fuzz/fuzz_targets/password_hash/verify_password.rs deleted file mode 100644 index 44c5aaff..00000000 --- a/fuzz/fuzz_targets/password_hash/verify_password.rs +++ /dev/null @@ -1,16 +0,0 @@ -#![no_main] -use arbitrary::Arbitrary; -use libfuzzer_sys::fuzz_target; - -use devolutions_crypto::password_hash::PasswordHash; - -#[derive(Arbitrary, Clone, Debug)] -struct Input { - data: Vec, - hash: PasswordHash, -} - -fuzz_target!(|data: Input| { - // Hardcode 2 iterations so it won't be too CPU demanding. - let _ = data.hash.verify_password(&data.data); -}); diff --git a/python/PYPI_README.md b/python/PYPI_README.md index 70da05dd..fb16a421 100644 --- a/python/PYPI_README.md +++ b/python/PYPI_README.md @@ -17,9 +17,9 @@ pip install devolutions-crypto - **Symmetric Encryption**: Fast AES-256-GCM encryption for shared-key scenarios - **Asymmetric Encryption**: X25519-based public-key encryption -- **Password Hashing**: Secure password hashing with PBKDF2 +- **Password Hashing**: Secure password hashing with Argon2 and PBKDF2 - **Digital Signatures**: Ed25519 signatures for data authentication -- **Key Derivation**: PBKDF2 and Argon2 key derivation functions +- **Key Derivation**: Argon2 and PBKDF2 key derivation functions - **Type Safety**: Full type hints and IDE support via stub files ## Quick Start @@ -150,7 +150,6 @@ import devolutions_crypto password = b"my_secure_password123!" password_hash = devolutions_crypto.hash_password( password, - iterations=600000, # Higher is more secure but slower version=0 ) diff --git a/python/devolutions_crypto.pyi b/python/devolutions_crypto.pyi index c0e8a892..925abb55 100644 --- a/python/devolutions_crypto.pyi +++ b/python/devolutions_crypto.pyi @@ -127,16 +127,17 @@ def decrypt_asymmetric( def hash_password( password: bytes, - iterations: int = 600000, version: int = 0 ) -> bytes: """ - Hash a password using a secure password hashing algorithm (PBKDF2). + Hash a password using a secure password hashing algorithm. + + Uses Argon2id (V2) by default. + Use ``version=1`` for PBKDF2-SHA256. Args: password: The password to hash - iterations: Number of iterations for the KDF (default: 600000, higher is more secure) - version: Password hash version (default: 0) + version: Password hash version (default: 0 = Latest = Argon2id V2) Returns: The password hash as bytes (contains salt and parameters) @@ -146,7 +147,32 @@ def hash_password( Example: >>> password = b'my_secure_password' - >>> hash_value = hash_password(password, iterations=600000) + >>> hash_value = hash_password(password) + >>> assert verify_password(password, hash_value) + """ + ... + +def hash_password_with_params( + password: bytes, + params: bytes +) -> bytes: + """ + Hash a password using serialized DerivationParameters. + + Args: + password: The password to hash + params: Serialized DerivationParameters bytes (Argon2id or PBKDF2) + + Returns: + The password hash as bytes + + Raises: + DevolutionsCryptoException: If hashing fails or invalid parameters + + Example: + >>> result = derive_secret_key_argon2(b'seed') + >>> hash_value = hash_password_with_params(b'my_secure_password', result.parameters) + >>> assert verify_password(b'my_secure_password', hash_value) """ ... @@ -220,6 +246,46 @@ def derive_key_argon2( """ ... +def get_argon2_derivation_parameters(parameters: bytes | None = None) -> bytes: + """ + Build serialized ``DerivationParameters`` from the given Argon2 parameters without + performing any key derivation. + + Args: + parameters: Serialized Argon2Parameters bytes. Uses library defaults when ``None``. + + Returns: + Serialized DerivationParameters bytes suitable for :func:`hash_password_with_params`. + + Raises: + DevolutionsCryptoException: If the parameters are invalid. + + Example: + >>> dp = get_argon2_derivation_parameters() + >>> hashed = hash_password_with_params(b'my password', dp) + """ + ... + +def get_pbkdf2_derivation_parameters(iterations: int = 600000) -> bytes: + """ + Build serialized ``DerivationParameters`` for PBKDF2 with the given iteration count, + without performing any key derivation. + + Args: + iterations: Number of PBKDF2 iterations (default: 600,000). + + Returns: + Serialized DerivationParameters bytes suitable for :func:`hash_password_with_params`. + + Raises: + DevolutionsCryptoException: If parameter generation fails. + + Example: + >>> dp = get_pbkdf2_derivation_parameters(iterations=600000) + >>> hashed = hash_password_with_params(b'my password', dp) + """ + ... + def generate_keypair(version: int = 0) -> Keypair: """ Generate a new asymmetric encryption keypair (X25519). diff --git a/python/pyproject.toml b/python/pyproject.toml index 9e72ff8d..ab782e32 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "devolutions-crypto" -version = "2026.1.13" +version = "2026.6.1" description = "An abstraction layer for the cryptography used by Devolutions" readme = "PYPI_README.md" authors = [ diff --git a/python/src/lib.rs b/python/src/lib.rs index e33ca90b..cade0f13 100644 --- a/python/src/lib.rs +++ b/python/src/lib.rs @@ -96,13 +96,8 @@ fn encrypt_asymmetric( #[pyfunction] #[pyo3(name = "hash_password")] -#[pyo3(signature = (password, iterations=600000, version=0))] -fn hash_password( - py: Python, - password: &[u8], - iterations: u32, - version: u16, -) -> Result> { +#[pyo3(signature = (password, version=0))] +fn hash_password(py: Python, password: &[u8], version: u16) -> Result> { let version = match devolutions_crypto::password_hash::PasswordHashVersion::try_from(version) { Ok(v) => v, Err(_) => { @@ -111,8 +106,16 @@ fn hash_password( } }; + let hash: Vec = devolutions_crypto::password_hash::hash_password(password, version)?.into(); + Ok(PyBytes::new(py, &hash).into()) +} + +#[pyfunction] +#[pyo3(name = "hash_password_with_params")] +fn hash_password_with_params(py: Python, password: &[u8], params: &[u8]) -> Result> { + let dp = devolutions_crypto::key_derivation::DerivationParameters::try_from(params)?; let hash: Vec = - devolutions_crypto::password_hash::hash_password(password, iterations, version)?.into(); + devolutions_crypto::password_hash::hash_password_with_parameters(password, dp)?.into(); Ok(PyBytes::new(py, &hash).into()) } @@ -183,6 +186,30 @@ fn derive_key_argon2(py: Python, key: &[u8], parameters: &[u8]) -> Result) -> Result> { + let params = match parameters { + Some(p) => Argon2Parameters::try_from(p)?, + None => Argon2Parameters::default(), + }; + let dp: Vec = devolutions_crypto::key_derivation::Argon2::with_params(params) + .parameters() + .into(); + Ok(PyBytes::new(py, &dp).into()) +} + +#[pyfunction] +#[pyo3(name = "get_pbkdf2_derivation_parameters")] +#[pyo3(signature = (iterations=600000))] +fn get_pbkdf2_derivation_parameters(py: Python, iterations: u32) -> Result> { + let dp: Vec = devolutions_crypto::key_derivation::Pbkdf2::with_params(iterations) + .parameters()? + .into(); + Ok(PyBytes::new(py, &dp).into()) +} + #[pyfunction] #[pyo3(name = "sign")] #[pyo3(signature = (data, keypair, version=0))] @@ -336,9 +363,12 @@ fn devolutions_crypto_module(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(encrypt_asymmetric, m)?)?; m.add_function(wrap_pyfunction!(decrypt_asymmetric, m)?)?; m.add_function(wrap_pyfunction!(hash_password, m)?)?; + m.add_function(wrap_pyfunction!(hash_password_with_params, m)?)?; m.add_function(wrap_pyfunction!(verify_password, m)?)?; m.add_function(wrap_pyfunction!(derive_key_pbkdf2, m)?)?; m.add_function(wrap_pyfunction!(derive_key_argon2, m)?)?; + m.add_function(wrap_pyfunction!(get_argon2_derivation_parameters, m)?)?; + m.add_function(wrap_pyfunction!(get_pbkdf2_derivation_parameters, m)?)?; m.add_function(wrap_pyfunction!(sign, m)?)?; m.add_function(wrap_pyfunction!(verify_signature, m)?)?; m.add_function(wrap_pyfunction!(generate_keypair, m)?)?; diff --git a/src/enums.rs b/src/enums.rs index afd23e24..5f45a9fa 100644 --- a/src/enums.rs +++ b/src/enums.rs @@ -77,6 +77,8 @@ pub enum PasswordHashVersion { Latest = 0, /// Uses version 1: PBKDF2-HMAC-SHA2-256. V1 = 1, + /// Uses version 2: Argon2id. + V2 = 2, } /// The versions of the key scheme to use. diff --git a/src/key_derivation/key_derivation_v1.rs b/src/key_derivation/key_derivation_v1.rs index ff9a19a5..559aa1c4 100644 --- a/src/key_derivation/key_derivation_v1.rs +++ b/src/key_derivation/key_derivation_v1.rs @@ -115,6 +115,26 @@ impl Pbkdf2 { Ok((secret_key, derivation_params)) } + + /// Returns a `DerivationParameters` capturing the current PBKDF2 settings with a freshly + /// generated random salt, without performing any derivation. + /// Useful for passing custom parameters to [`crate::password_hash::hash_password_with_parameters`]. + pub fn parameters(self) -> Result { + let mut salt = vec![0u8; 16]; + rand::rngs::OsRng + .try_fill_bytes(&mut salt) + .map_err(|_| Error::RandomError)?; + let v1 = KeyDerivationV1 { + iterations: self.iterations, + salt, + }; + let mut header: Header = Header::default(); + header.version = KeyDerivationVersion::V1; + Ok(DerivationParameters { + header, + payload: DerivationParametersPayload::V1(v1), + }) + } } impl Default for Pbkdf2 { diff --git a/src/key_derivation/key_derivation_v2.rs b/src/key_derivation/key_derivation_v2.rs index a2c111c9..ae4530d8 100644 --- a/src/key_derivation/key_derivation_v2.rs +++ b/src/key_derivation/key_derivation_v2.rs @@ -62,6 +62,20 @@ impl Argon2 { Self { params } } + /// Returns a `DerivationParameters` capturing the current Argon2 settings. + /// Useful for passing custom parameters to [`crate::password_hash::hash_password_with_parameters`]. + pub fn parameters(self) -> DerivationParameters { + let v2 = KeyDerivationV2 { + params: self.params, + }; + let mut header: Header = Header::default(); + header.version = KeyDerivationVersion::V2; + DerivationParameters { + header, + payload: DerivationParametersPayload::V2(v2), + } + } + /// Derives the key using the configured Argon2 parameters. /// The salt is embedded in `Argon2Parameters` (generated at construction time when using `new()`). pub fn derive(&self, key: &[u8]) -> Result<(SecretKey, DerivationParameters)> { diff --git a/src/key_derivation/mod.rs b/src/key_derivation/mod.rs index e6f4e5c7..ec13b3da 100644 --- a/src/key_derivation/mod.rs +++ b/src/key_derivation/mod.rs @@ -39,6 +39,8 @@ use arbitrary::Arbitrary; #[cfg(feature = "wbindgen")] use wasm_bindgen::prelude::*; +use zeroize::Zeroizing; + use crate::key::SecretKey; #[cfg(feature = "fuzz")] use crate::Argon2Parameters; @@ -135,6 +137,24 @@ impl From for Vec { } } +impl DerivationParameters { + /// Re-derives raw bytes from a password using the stored algorithm and parameters. + pub fn compute(&self, password: &[u8]) -> Result>> { + match &self.payload { + DerivationParametersPayload::V1(v1) => Ok(v1.derive(password)), + DerivationParametersPayload::V2(v2) => v2.derive(password), + } + } + + /// Returns the byte-length of the hash that [`compute`](Self::compute) will produce. + pub fn output_length(&self) -> usize { + match &self.payload { + DerivationParametersPayload::V1(_) => key_derivation_v1::KEY_LENGTH, + DerivationParametersPayload::V2(v2) => v2.params.length as usize, + } + } +} + /// Derives a `SecretKey` from `password` using the algorithm selected by `version`. /// /// * `KeyDerivationVersion::Latest` and `KeyDerivationVersion::V2` use **Argon2id** (recommended). diff --git a/src/lib.rs b/src/lib.rs index 1cb508cf..77cf04f2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -164,7 +164,7 @@ //! //! let password = b"somesuperstrongpa$$w0rd!"; //! -//! let hashed_password = hash_password(password, 600000, PasswordHashVersion::Latest).expect("hash password shouldn't fail");; +//! let hashed_password = hash_password(password, PasswordHashVersion::Latest).expect("hash password shouldn't fail"); //! //! assert!(hashed_password.verify_password(b"somesuperstrongpa$$w0rd!")); //! assert!(!hashed_password.verify_password(b"someweakpa$$w0rd!")); diff --git a/src/password_hash/mod.rs b/src/password_hash/mod.rs index b76ee6e8..44f0710f 100644 --- a/src/password_hash/mod.rs +++ b/src/password_hash/mod.rs @@ -1,18 +1,23 @@ //! Module for password hashing and verification. Use this if you need to store user passwords. //! -//! You can use this module to hash a password and validate it afterward. This is the recommended way to verify a user password on login. +//! You can use this module to hash a password and validate it afterward. This is the recommended +//! way to verify a user password on login. +//! +//! The default algorithm (`PasswordHashVersion::Latest`) is **Argon2id**. +//! //! ```rust //! use devolutions_crypto::password_hash::{hash_password, PasswordHashVersion}; //! //! let password = b"somesuperstrongpa$$w0rd!"; //! -//! let hashed_password = hash_password(password, 600000, PasswordHashVersion::Latest).expect("hash password shouldn't fail");; +//! let hashed_password = hash_password(password, PasswordHashVersion::Latest).expect("hash password shouldn't fail"); //! //! assert!(hashed_password.verify_password(b"somesuperstrongpa$$w0rd!")); //! assert!(!hashed_password.verify_password(b"someweakpa$$w0rd!")); //! ``` mod password_hash_v1; +mod password_hash_v2; use super::DataType; use super::Error; @@ -22,7 +27,13 @@ use super::PasswordHashSubtype; pub use super::PasswordHashVersion; use super::Result; +use super::Argon2; +use super::Argon2Parameters; +use super::DEFAULT_PBKDF2_ITERATIONS; +use crate::key_derivation::DerivationParameters; + use password_hash_v1::PasswordHashV1; +use password_hash_v2::PasswordHashV2; use std::borrow::Borrow; use std::convert::TryFrom; @@ -51,14 +62,21 @@ impl HeaderType for PasswordHash { #[cfg_attr(feature = "fuzz", derive(Arbitrary))] enum PasswordHashPayload { V1(PasswordHashV1), + V2(PasswordHashV2), } /// Creates a `PasswordHash` containing the password verifier. +/// +/// Uses a secure default for each version: +/// - `V1` (PBKDF2-HMAC-SHA256): [`DEFAULT_PBKDF2_ITERATIONS`] iterations. +/// - `V2` / `Latest` (Argon2id): 64 MiB memory, 3 time iterations (OWASP recommendation). +/// +/// Use [`hash_password_with_parameters`] when you need to tune the algorithm parameters. +/// /// # Arguments /// * `password` - The password to hash. -/// * `iterations` - The number of iterations of the password hash. -/// A higher number is slower but harder to brute-force (recommended value is 600,000 or higher) -/// * `version` - Version of the library to hash the password with. Use `PasswordHashVersion::Latest` if you're not dealing with shared data. +/// * `version` - Version of the algorithm to use. Use `PasswordHashVersion::Latest` if you're +/// not dealing with shared data. /// # Returns /// Returns the `PasswordHash` containing the password verifier. /// # Example @@ -67,25 +85,62 @@ enum PasswordHashPayload { /// /// let password = b"somesuperstrongpa$$w0rd!"; /// -/// let hashed_password = hash_password(password, 600000, PasswordHashVersion::Latest); +/// let hashed_password = hash_password(password, PasswordHashVersion::Latest); /// ``` -pub fn hash_password( - password: &[u8], - iterations: u32, - version: PasswordHashVersion, -) -> Result { +pub fn hash_password(password: &[u8], version: PasswordHashVersion) -> Result { let mut header = Header::default(); let payload = match version { - PasswordHashVersion::V1 | PasswordHashVersion::Latest => { + PasswordHashVersion::V1 => { header.version = PasswordHashVersion::V1; - PasswordHashPayload::V1(PasswordHashV1::hash_password(password, iterations)?) + PasswordHashPayload::V1(PasswordHashV1::hash_password( + password, + DEFAULT_PBKDF2_ITERATIONS, + )?) + } + PasswordHashVersion::V2 | PasswordHashVersion::Latest => { + header.version = PasswordHashVersion::V2; + let mut argon2_params = Argon2Parameters::default(); + argon2_params.memory = password_hash_v2::defaults::MEMORY_KIB; + argon2_params.iterations = password_hash_v2::defaults::ITERATIONS; + let params = Argon2::with_params(argon2_params).parameters(); + PasswordHashPayload::V2(PasswordHashV2::hash_password(password, params)?) } }; Ok(PasswordHash { header, payload }) } +/// Creates a `PasswordHash` using caller-supplied [`DerivationParameters`]. +/// +/// The derivation algorithm (Argon2id or PBKDF2) is determined by what is encoded inside +/// `params`. Obtain fresh parameters via [`crate::key_derivation::Argon2::parameters`] or +/// [`crate::key_derivation::Pbkdf2::parameters`]. +/// +/// # Example +/// ```rust +/// use devolutions_crypto::password_hash::hash_password_with_parameters; +/// use devolutions_crypto::key_derivation::Argon2; +/// use devolutions_crypto::Argon2Parameters; +/// +/// let mut params_cfg = Argon2Parameters::default(); +/// params_cfg.memory = 131072; // 128 MiB +/// params_cfg.iterations = 4; +/// let params = Argon2::with_params(params_cfg).parameters(); +/// +/// let hash = hash_password_with_parameters(b"pa$$word", params).expect("should not fail"); +/// assert!(hash.verify_password(b"pa$$word")); +/// ``` +pub fn hash_password_with_parameters( + password: &[u8], + params: DerivationParameters, +) -> Result { + let mut header = Header::default(); + header.version = PasswordHashVersion::V2; + let payload = PasswordHashPayload::V2(PasswordHashV2::hash_password(password, params)?); + Ok(PasswordHash { header, payload }) +} + impl PasswordHash { /// Verify if the `PasswordHash` matches with the specified password. Should execute in constant time. /// # Arguments @@ -98,13 +153,14 @@ impl PasswordHash { /// /// let password = b"somesuperstrongpa$$w0rd!"; /// - /// let hashed_password = hash_password(password, 600000, PasswordHashVersion::Latest).expect("hash password shouldn't fail");; + /// let hashed_password = hash_password(password, PasswordHashVersion::Latest).expect("hash password shouldn't fail"); /// assert!(hashed_password.verify_password(b"somesuperstrongpa$$w0rd!")); /// assert!(!hashed_password.verify_password(b"someweakpa$$w0rd!")); /// ``` pub fn verify_password(&self, password: &[u8]) -> bool { match &self.payload { PasswordHashPayload::V1(x) => x.verify_password(password), + PasswordHashPayload::V2(x) => x.verify_password(password), } } } @@ -134,6 +190,9 @@ impl TryFrom<&[u8]> for PasswordHash { PasswordHashVersion::V1 => { PasswordHashPayload::V1(PasswordHashV1::try_from(&data[Header::len()..])?) } + PasswordHashVersion::V2 => { + PasswordHashPayload::V2(PasswordHashV2::try_from(&data[Header::len()..])?) + } _ => return Err(Error::UnknownVersion), }; @@ -145,17 +204,47 @@ impl From for Vec { fn from(data: PasswordHashPayload) -> Self { match data { PasswordHashPayload::V1(x) => x.into(), + PasswordHashPayload::V2(x) => x.into(), } } } #[test] -fn password_test() { +fn hash_password_test() { let pass = "thisisaveryveryverystrongPa$$w0rd , //".as_bytes(); - let iterations = 10u32; - - let hash = hash_password(pass, iterations, PasswordHashVersion::Latest).unwrap(); + let hash = hash_password(pass, PasswordHashVersion::Latest).unwrap(); assert!(hash.verify_password(pass)); assert!(!hash.verify_password("averybadpassword".as_bytes())) } + +#[test] +fn password_v2_roundtrip_bytes() { + let pass = b"pa$$w0rd"; + + let mut argon2_params = Argon2Parameters::default(); + argon2_params.memory = 32; + argon2_params.iterations = 2; + let params = Argon2::with_params(argon2_params).parameters(); + let hash = hash_password_with_parameters(pass, params).unwrap(); + let bytes: Vec = hash.into(); + + let hash2 = PasswordHash::try_from(bytes.as_slice()).unwrap(); + assert!(hash2.verify_password(pass)); + assert!(!hash2.verify_password(b"wrongpassword")); +} + +#[test] +fn password_v1_roundtrip_bytes() { + use crate::key_derivation::Pbkdf2; + + let pass = b"pa$$word"; + // Use very low iterations so the test finishes quickly. + let params = Pbkdf2::with_params(10).parameters().unwrap(); + let hash = hash_password_with_parameters(pass, params).unwrap(); + let bytes: Vec = hash.into(); + + let hash2 = PasswordHash::try_from(bytes.as_slice()).unwrap(); + assert!(hash2.verify_password(pass)); + assert!(!hash2.verify_password(b"wrongpassword")); +} diff --git a/src/password_hash/password_hash_v2.rs b/src/password_hash/password_hash_v2.rs new file mode 100644 index 00000000..4cc2650c --- /dev/null +++ b/src/password_hash/password_hash_v2.rs @@ -0,0 +1,103 @@ +//! Password Hash V2: Argon2id via [`crate::key_derivation::DerivationParameters`]. +//! +//! The on-disk payload layout (after the 8-byte [`crate::Header`]) is: +//! +//! ```text +//! [ u32 LE: params_len ][ DerivationParameters bytes (params_len) ][ hash bytes ] +//! ``` +use std::convert::TryFrom; +use std::io::Cursor; + +use byteorder::{LittleEndian, ReadBytesExt, WriteBytesExt}; +use subtle::ConstantTimeEq as _; +use zeroize::Zeroizing; + +use crate::key_derivation::DerivationParameters; +use crate::{Error, Result}; + +#[cfg(feature = "fuzz")] +use arbitrary::Arbitrary; + +pub mod defaults { + /// Memory cost in KiB (64 MiB). + pub const MEMORY_KIB: u32 = 65536; + /// Time cost (number of passes). + pub const ITERATIONS: u32 = 3; +} + +/// Argon2id-backed password verifier (V2). +/// +/// Stores the [`DerivationParameters`] (algorithm + salt) together with the derived hash so +/// that verification can reproduce the derivation deterministically. +#[derive(Clone, Debug)] +pub struct PasswordHashV2 { + pub(super) params: DerivationParameters, + hash: Zeroizing>, +} + +#[cfg(feature = "fuzz")] +impl Arbitrary for PasswordHashV2 { + fn arbitrary(u: &mut arbitrary::Unstructured<'_>) -> arbitrary::Result { + Ok(Self { + params: DerivationParameters::arbitrary(u)?, + hash: Zeroizing::new(Vec::::arbitrary(u)?), + }) + } +} + +impl PasswordHashV2 { + /// Hashes `pass` using the supplied `params` and returns the verifier. + pub fn hash_password(pass: &[u8], params: DerivationParameters) -> Result { + let hash = params.compute(pass)?; + Ok(PasswordHashV2 { params, hash }) + } + + /// Returns `true` if `pass` reproduces the stored hash. + pub fn verify_password(&self, pass: &[u8]) -> bool { + match self.params.compute(pass) { + Ok(derived) => derived.ct_eq(&self.hash).into(), + Err(_) => false, + } + } +} + +impl From for Vec { + fn from(v2: PasswordHashV2) -> Vec { + let params_bytes: Vec = v2.params.into(); + let params_len = params_bytes.len() as u32; + let mut data = Vec::with_capacity(4 + params_bytes.len() + v2.hash.len()); + data.write_u32::(params_len).unwrap(); + data.extend_from_slice(¶ms_bytes); + data.extend_from_slice(&v2.hash); + data + } +} + +impl TryFrom<&[u8]> for PasswordHashV2 { + type Error = Error; + + fn try_from(data: &[u8]) -> Result { + if data.len() < 4 { + return Err(Error::InvalidLength); + } + let mut cursor = Cursor::new(data); + let params_len = cursor.read_u32::()? as usize; + let pos = cursor.position() as usize; + + let params_end = pos.checked_add(params_len).ok_or(Error::InvalidLength)?; + if data.len() < params_end { + return Err(Error::InvalidLength); + } + let params = DerivationParameters::try_from(&data[pos..params_end])?; + + let hash_bytes = &data[params_end..]; + if hash_bytes.len() != params.output_length() { + return Err(Error::InvalidLength); + } + + Ok(PasswordHashV2 { + params, + hash: Zeroizing::new(hash_bytes.to_vec()), + }) + } +} diff --git a/src/wasm.rs b/src/wasm.rs index 6abfc1f2..c9f17f69 100644 --- a/src/wasm.rs +++ b/src/wasm.rs @@ -231,15 +231,18 @@ pub fn decrypt_asymmetric( #[wasm_bindgen(js_name = "hashPassword")] pub fn hash_password( password: &[u8], - iterations: Option, version: Option, ) -> Result, JsValue> { - Ok(password_hash::hash_password( - &password, - iterations.unwrap_or(DEFAULT_PBKDF2_ITERATIONS), - version.unwrap_or(PasswordHashVersion::Latest), - )? - .into()) + Ok( + password_hash::hash_password(&password, version.unwrap_or(PasswordHashVersion::Latest))? + .into(), + ) +} + +#[wasm_bindgen(js_name = "hashPasswordWithParams")] +pub fn hash_password_with_params(password: &[u8], params: &[u8]) -> Result, JsValue> { + let params = DerivationParameters::try_from(params)?; + Ok(password_hash::hash_password_with_parameters(password, params)?.into()) } #[wasm_bindgen(js_name = "verifyPassword")] @@ -452,6 +455,21 @@ pub fn derive_secret_key_argon2( }) } +/// Build `DerivationParameters` from the given `Argon2Parameters` without performing any key +/// derivation. The result can be passed to `hashPasswordWithParams`. +#[wasm_bindgen(js_name = "getArgon2DerivationParameters")] +pub fn get_argon2_derivation_parameters(parameters: &Argon2Parameters) -> Vec { + Argon2::with_params(parameters.clone()).parameters().into() +} + +/// Build `DerivationParameters` for PBKDF2 with the given iteration count without performing +/// any key derivation. The result can be passed to `hashPasswordWithParams`. +#[wasm_bindgen(js_name = "getPbkdf2DerivationParameters")] +pub fn get_pbkdf2_derivation_parameters(iterations: Option) -> Result, JsValue> { + let it = iterations.unwrap_or(DEFAULT_PBKDF2_ITERATIONS); + Ok(Pbkdf2::with_params(it).parameters()?.into()) +} + #[wasm_bindgen(js_name = "validateHeader")] pub fn validate_header(data: &[u8], data_type: DataType) -> bool { utils::validate_header(data, data_type) diff --git a/tests/conformity.rs b/tests/conformity.rs index c04e1efe..088f127e 100644 --- a/tests/conformity.rs +++ b/tests/conformity.rs @@ -243,6 +243,22 @@ fn test_password_hashing_v1() { assert!(hash2.verify_password(b"password1")); } +#[test] +fn test_password_hashing_v2() { + // Argon2id hash for "password1" with memory=65536 KiB, iterations=3, salt="conformity_salt!" + // Generated with: Argon2Parameters { memory: 65536, iterations: 3, salt: "conformity_salt!" } + let hash = PasswordHash::try_from( + general_purpose::STANDARD + .decode("DQwDAAAAAgA2AAAADQwIAAAAAgABAAAAIAAAAAEAAAAAAAEAAwAAAAITAAAAABAAAABjb25mb3JtaXR5X3NhbHQh0qPjrO8QR1p1wS+cThwEhBO+ouwFraKqpo0TVJLosMM=") + .unwrap() + .as_slice(), + ) + .unwrap(); + + assert!(hash.verify_password(b"password1")); + assert!(!hash.verify_password(b"password2")); +} + #[test] fn test_signature_v1() { use devolutions_crypto::signature::Signature; diff --git a/tests/web.rs b/tests/web.rs index c0e3f4ab..22d84792 100644 --- a/tests/web.rs +++ b/tests/web.rs @@ -23,7 +23,7 @@ cfg_if! { fn test_hash_password() { let password = "ThisIsAGoodPassword123".as_bytes(); - let hash = wasm::hash_password(password, Some(123), None).unwrap(); + let hash = wasm::hash_password(password, None).unwrap(); assert!(wasm::verify_password(password, &hash).unwrap()); let bad_password = "thisisabadpassword1234".as_bytes(); diff --git a/uniffi/devolutions-crypto-uniffi/src/key_derivation.rs b/uniffi/devolutions-crypto-uniffi/src/key_derivation.rs index d23835d1..ea8ff3d3 100644 --- a/uniffi/devolutions-crypto-uniffi/src/key_derivation.rs +++ b/uniffi/devolutions-crypto-uniffi/src/key_derivation.rs @@ -45,3 +45,19 @@ pub fn derive_secret_key_argon2( parameters: params.into(), }) } + +#[uniffi::export] +pub fn get_argon2_derivation_parameters(parameters: &Arc) -> Vec { + devolutions_crypto::key_derivation::Argon2::with_params(parameters.inner.clone()) + .parameters() + .into() +} + +#[uniffi::export(default(iterations = 600000))] +pub fn get_pbkdf2_derivation_parameters(iterations: u32) -> Result> { + Ok( + devolutions_crypto::key_derivation::Pbkdf2::with_params(iterations) + .parameters()? + .into(), + ) +} diff --git a/uniffi/devolutions-crypto-uniffi/src/lib.rs b/uniffi/devolutions-crypto-uniffi/src/lib.rs index a3fb121d..c5c8c42b 100644 --- a/uniffi/devolutions-crypto-uniffi/src/lib.rs +++ b/uniffi/devolutions-crypto-uniffi/src/lib.rs @@ -60,6 +60,7 @@ pub enum KeyDerivationVersion { pub enum PasswordHashVersion { Latest, V1, + V2, } #[uniffi::remote(Enum)] diff --git a/uniffi/devolutions-crypto-uniffi/src/password_hash.rs b/uniffi/devolutions-crypto-uniffi/src/password_hash.rs index 7bd58ad0..46649895 100644 --- a/uniffi/devolutions-crypto-uniffi/src/password_hash.rs +++ b/uniffi/devolutions-crypto-uniffi/src/password_hash.rs @@ -1,14 +1,16 @@ use crate::PasswordHashVersion; use crate::Result; -#[uniffi::export(default(iterations = 10000, version = None))] -pub fn hash_password( - password: &[u8], - iterations: u32, - version: Option, -) -> Result> { +#[uniffi::export(default(version = None))] +pub fn hash_password(password: &[u8], version: Option) -> Result> { let version = version.unwrap_or(PasswordHashVersion::Latest); - Ok(devolutions_crypto::password_hash::hash_password(password, iterations, version)?.into()) + Ok(devolutions_crypto::password_hash::hash_password(password, version)?.into()) +} + +#[uniffi::export] +pub fn hash_password_with_params(password: &[u8], params: &[u8]) -> Result> { + let dp = devolutions_crypto::key_derivation::DerivationParameters::try_from(params)?; + Ok(devolutions_crypto::password_hash::hash_password_with_parameters(password, dp)?.into()) } #[uniffi::export] diff --git a/wrappers/csharp/src/Managed.cs b/wrappers/csharp/src/Managed.cs index 527d5870..56e17dba 100644 --- a/wrappers/csharp/src/Managed.cs +++ b/wrappers/csharp/src/Managed.cs @@ -96,6 +96,63 @@ public static Argon2Parameters GetDefaultArgon2Parameters() return parameters; } + /// + /// Build serialized from the given + /// without performing any key derivation. + /// + /// + /// The Argon2 parameters to use. Defaults to when null. + /// + /// Serialized bytes suitable for use with . + public static byte[] GetArgon2DerivationParameters(Argon2Parameters? parameters = null) + { + Argon2Parameters argon2Params = parameters ?? GetDefaultArgon2Parameters(); + byte[] rawParams = argon2Params.ToByteArray(); + + long size = Native.GetArgon2DerivationParametersSizeNative((UIntPtr)rawParams.Length); + + if (size < 0) + { + Utils.HandleError(size); + } + + byte[] result = new byte[size]; + long res = Native.GetArgon2DerivationParametersNative(rawParams, (UIntPtr)rawParams.Length, result, (UIntPtr)result.Length); + + if (res < 0) + { + Utils.HandleError(res); + } + + return result; + } + + /// + /// Build serialized for PBKDF2 with the given iteration count, + /// without performing any key derivation. + /// + /// Number of PBKDF2 iterations. Defaults to 600,000. + /// Serialized bytes suitable for use with . + public static byte[] GetPbkdf2DerivationParameters(uint iterations = DEFAULT_PBKDF2_ITERATIONS) + { + long size = Native.GetPbkdf2DerivationParametersSizeNative(); + + if (size < 0) + { + Utils.HandleError(size); + } + + byte[] result = new byte[size]; + long res = Native.GetPbkdf2DerivationParametersNative(iterations, result, (UIntPtr)result.Length); + + if (res < 0) + { + Utils.HandleError(res); + } + + return result; + } + /// /// Encrypts the data (which will be encoded into a UTF8 byte array) with the provided key. /// @@ -614,11 +671,11 @@ public static bool VerifyPassword(byte[] password, byte[] hash, ILegacyHasher? l /// /// Hash a password. + /// Use to supply custom . /// /// The password to hash in bytes. - /// The number of iterations used to hash the password (defaults to 600,000). /// Returns the hashed password in bytes. - public static byte[] HashPassword(byte[] password, uint iterations = DEFAULT_PBKDF2_ITERATIONS) + public static byte[] HashPassword(byte[] password) { if (password == null || password.Length == 0) { @@ -633,7 +690,39 @@ public static byte[] HashPassword(byte[] password, uint iterations = DEFAULT_PBK } byte[] result = new byte[hashLength]; - long res = Native.HashPasswordNative(password, (UIntPtr)password.Length, iterations, result, (UIntPtr)result.Length); + long res = Native.HashPasswordNative(password, (UIntPtr)password.Length, result, (UIntPtr)result.Length); + + if (res < 0) + { + Utils.HandleError(res); + } + + return result; + } + + /// + /// Hash a password using the supplied serialized . + /// Use for the default Argon2id behaviour. + /// + /// The password to hash in bytes. + /// Serialized bytes. + /// Returns the hashed password in bytes. + public static byte[] HashPasswordWithParams(byte[] password, byte[] derivationParams) + { + if (password == null || password.Length == 0 || derivationParams == null || derivationParams.Length == 0) + { + throw new DevolutionsCryptoException(ManagedError.InvalidParameter); + } + + long hashLength = Native.HashPasswordWithParamsLengthNative(derivationParams, (UIntPtr)derivationParams.Length); + + if (hashLength < 0) + { + Utils.HandleError(hashLength); + } + + byte[] result = new byte[hashLength]; + long res = Native.HashPasswordWithParamsNative(password, (UIntPtr)password.Length, derivationParams, (UIntPtr)derivationParams.Length, result, (UIntPtr)result.Length); if (res < 0) { diff --git a/wrappers/csharp/src/Native.Core.cs b/wrappers/csharp/src/Native.Core.cs index cd2c0c75..9242649f 100644 --- a/wrappers/csharp/src/Native.Core.cs +++ b/wrappers/csharp/src/Native.Core.cs @@ -60,6 +60,18 @@ public static partial class Native [DllImport(LibName, EntryPoint = "DeriveSecretKeyArgon2ParametersSize", CallingConvention = CallingConvention.Cdecl)] internal static extern long DeriveSecretKeyArgon2ParametersSizeNative(UIntPtr argon2ParametersLength); + [DllImport(LibName, EntryPoint = "GetArgon2DerivationParametersSize", CallingConvention = CallingConvention.Cdecl)] + internal static extern long GetArgon2DerivationParametersSizeNative(UIntPtr argon2ParametersLength); + + [DllImport(LibName, EntryPoint = "GetArgon2DerivationParameters", CallingConvention = CallingConvention.Cdecl)] + internal static extern long GetArgon2DerivationParametersNative(byte[] argon2Parameters, UIntPtr argon2ParametersLength, byte[] result, UIntPtr resultLength); + + [DllImport(LibName, EntryPoint = "GetPbkdf2DerivationParametersSize", CallingConvention = CallingConvention.Cdecl)] + internal static extern long GetPbkdf2DerivationParametersSizeNative(); + + [DllImport(LibName, EntryPoint = "GetPbkdf2DerivationParameters", CallingConvention = CallingConvention.Cdecl)] + internal static extern long GetPbkdf2DerivationParametersNative(uint iterations, byte[] result, UIntPtr resultLength); + [DllImport(LibName, EntryPoint = "DeriveSecretKeyPbkdf2WithSalt", CallingConvention = CallingConvention.Cdecl)] internal static extern long DeriveSecretKeyPbkdf2WithSaltNative(byte[] key, UIntPtr keyLength, System.UInt32 iterations, byte[] salt, UIntPtr saltLength, byte[] secretKey, UIntPtr secretKeyLength, byte[] paramsOut, UIntPtr paramsOutLength); @@ -110,7 +122,13 @@ public static partial class Native internal static extern long HashPasswordLengthNative(); [DllImport(LibName, EntryPoint = "HashPassword", CallingConvention = CallingConvention.Cdecl)] - internal static extern long HashPasswordNative(byte[] password, UIntPtr passwordLength, uint iterations, byte[] result, UIntPtr resultLength); + internal static extern long HashPasswordNative(byte[] password, UIntPtr passwordLength, byte[] result, UIntPtr resultLength); + + [DllImport(LibName, EntryPoint = "HashPasswordWithParamsLength", CallingConvention = CallingConvention.Cdecl)] + internal static extern long HashPasswordWithParamsLengthNative(byte[] derivationParams, UIntPtr derivationParamsLength); + + [DllImport(LibName, EntryPoint = "HashPasswordWithParams", CallingConvention = CallingConvention.Cdecl)] + internal static extern long HashPasswordWithParamsNative(byte[] password, UIntPtr passwordLength, byte[] derivationParams, UIntPtr derivationParamsLength, byte[] result, UIntPtr resultLength); [DllImport(LibName, EntryPoint = "KeySize", CallingConvention = CallingConvention.Cdecl)] internal static extern uint KeySizeNative(); diff --git a/wrappers/csharp/src/Native.cs b/wrappers/csharp/src/Native.cs index 020188a0..21e07f13 100644 --- a/wrappers/csharp/src/Native.cs +++ b/wrappers/csharp/src/Native.cs @@ -104,9 +104,9 @@ public static KeyPair GenerateKeyPair() } [Obsolete("This method has been deprecated. Use Managed.HashPassword instead.")] - public static byte[] HashPassword(byte[] password, uint iterations = 600000) + public static byte[] HashPassword(byte[] password) { - return Managed.HashPassword(password, iterations); + return Managed.HashPassword(password); } [Obsolete("This method has been deprecated. Use Managed.HashPassword instead.")] diff --git a/wrappers/csharp/tests/unit-tests/TestManaged.cs b/wrappers/csharp/tests/unit-tests/TestManaged.cs index 94584db0..5496d95c 100644 --- a/wrappers/csharp/tests/unit-tests/TestManaged.cs +++ b/wrappers/csharp/tests/unit-tests/TestManaged.cs @@ -369,6 +369,24 @@ public void HashPassword() Assert.IsFalse(Managed.VerifyPassword(secondHash, firstHash)); } + [TestMethod] + public void HashPasswordWithParams() + { + Argon2Parameters parameters = new() + { + Memory = 32, + Iterations = 2, + }; + byte[] derivationParams = Managed.GetArgon2DerivationParameters(parameters); + + byte[] hash = Managed.HashPasswordWithParams(TestData.BytesTestKey, derivationParams); + Assert.IsNotNull(hash); + Assert.IsTrue(hash.Length > 0); + + Assert.IsTrue(Managed.VerifyPassword(TestData.BytesTestKey, hash)); + Assert.IsFalse(Managed.VerifyPassword(TestData.BytesTestData, hash)); + } + [TestMethod] public void JoinShares() { diff --git a/wrappers/kotlin/lib/src/test/kotlin/org/devolutions/crypto/HashingTest.kt b/wrappers/kotlin/lib/src/test/kotlin/org/devolutions/crypto/HashingTest.kt index ad865380..8a17d241 100644 --- a/wrappers/kotlin/lib/src/test/kotlin/org/devolutions/crypto/HashingTest.kt +++ b/wrappers/kotlin/lib/src/test/kotlin/org/devolutions/crypto/HashingTest.kt @@ -9,7 +9,7 @@ class HashingTest { @Test fun passwordHashTest() { val password = "password".toByteArray(Charsets.UTF_8) - val hash = hashPassword(password, 10u) + val hash = hashPassword(password) assert(verifyPassword(password, hash)) } @@ -17,7 +17,7 @@ class HashingTest { @Test fun wrongPasswordTest() { val password = "password".toByteArray(Charsets.UTF_8) - val hash = hashPassword(password, 10u) + val hash = hashPassword(password) assert(!verifyPassword("pa\$\$word".toByteArray(), hash)) assert(!verifyPassword("Password".toByteArray(), hash)) diff --git a/wrappers/python/tests/password_hash.py b/wrappers/python/tests/password_hash.py new file mode 100644 index 00000000..16b461f5 --- /dev/null +++ b/wrappers/python/tests/password_hash.py @@ -0,0 +1,65 @@ +import unittest +import devolutions_crypto + + +class TestPasswordHash(unittest.TestCase): + def test_hash_password_default(self): + """Default version uses Argon2id (V2).""" + password = b'my_secure_password' + hash_value = devolutions_crypto.hash_password(password) + self.assertTrue(devolutions_crypto.verify_password(password, hash_value)) + + def test_hash_password_v1_pbkdf2(self): + """Explicit V1 uses PBKDF2-SHA256.""" + password = b'my_secure_password' + hash_value = devolutions_crypto.hash_password(password, version=1) + self.assertTrue(devolutions_crypto.verify_password(password, hash_value)) + + def test_verify_wrong_password(self): + """verify_password returns False for incorrect password.""" + hash_value = devolutions_crypto.hash_password(b'correct_password') + self.assertFalse(devolutions_crypto.verify_password(b'wrong_password', hash_value)) + + def test_hash_is_non_deterministic(self): + """Two hashes of the same password are different (random salt).""" + password = b'same_password' + hash1 = devolutions_crypto.hash_password(password) + hash2 = devolutions_crypto.hash_password(password) + self.assertNotEqual(hash1, hash2) + self.assertTrue(devolutions_crypto.verify_password(password, hash1)) + self.assertTrue(devolutions_crypto.verify_password(password, hash2)) + + def test_hash_password_with_argon2_params(self): + """hash_password_with_params works with default Argon2id parameters.""" + password = b'my_secure_password' + params = devolutions_crypto.get_argon2_derivation_parameters() + hash_value = devolutions_crypto.hash_password_with_params(password, params) + self.assertTrue(devolutions_crypto.verify_password(password, hash_value)) + self.assertFalse(devolutions_crypto.verify_password(b'wrong_password', hash_value)) + + def test_hash_password_with_pbkdf2_params(self): + """hash_password_with_params works with PBKDF2 parameters.""" + password = b'my_secure_password' + params = devolutions_crypto.get_pbkdf2_derivation_parameters(iterations=10000) + hash_value = devolutions_crypto.hash_password_with_params(password, params) + self.assertTrue(devolutions_crypto.verify_password(password, hash_value)) + self.assertFalse(devolutions_crypto.verify_password(b'wrong_password', hash_value)) + + def test_verify_invalid_hash_raises(self): + """verify_password raises on invalid/truncated hash bytes.""" + with self.assertRaises(devolutions_crypto.DevolutionsCryptoException): + devolutions_crypto.verify_password(b'password', b'not_a_valid_hash') + + def test_hash_password_unknown_version_raises(self): + """hash_password raises on an unknown version number.""" + with self.assertRaises(devolutions_crypto.DevolutionsCryptoException): + devolutions_crypto.hash_password(b'password', version=999) + + def test_hash_password_with_params_invalid_params_raises(self): + """hash_password_with_params raises on invalid DerivationParameters bytes.""" + with self.assertRaises(devolutions_crypto.DevolutionsCryptoException): + devolutions_crypto.hash_password_with_params(b'password', b'invalid_params') + + +if __name__ == "__main__": + unittest.main() diff --git a/wrappers/swift/DevolutionsCryptoSwift/Tests/DevolutionsCryptoSwiftTests/HashingTests.swift b/wrappers/swift/DevolutionsCryptoSwift/Tests/DevolutionsCryptoSwiftTests/HashingTests.swift index 37ff97d3..a008896b 100644 --- a/wrappers/swift/DevolutionsCryptoSwift/Tests/DevolutionsCryptoSwiftTests/HashingTests.swift +++ b/wrappers/swift/DevolutionsCryptoSwift/Tests/DevolutionsCryptoSwiftTests/HashingTests.swift @@ -4,14 +4,14 @@ import XCTest class HashingTests: XCTestCase { func testPasswordHash() throws { let password = Data("password".utf8) - let hash = try hashPassword(password: password, iterations: 10) + let hash = try hashPassword(password: password) XCTAssertTrue(try verifyPassword(password: password, hash: hash)) } func testWrongPassword() throws { let password = Data("password".utf8) - let hash = try hashPassword(password: password, iterations: 10) + let hash = try hashPassword(password: password) XCTAssertFalse(try verifyPassword(password: Data("pa$$word".utf8), hash: hash)) XCTAssertFalse(try verifyPassword(password: Data("Password".utf8), hash: hash)) diff --git a/wrappers/wasm/demo/src/app/inspect/inspect.component.ts b/wrappers/wasm/demo/src/app/inspect/inspect.component.ts index bca44dee..104b11dc 100644 --- a/wrappers/wasm/demo/src/app/inspect/inspect.component.ts +++ b/wrappers/wasm/demo/src/app/inspect/inspect.component.ts @@ -36,7 +36,7 @@ const SUBTYPE_NAMES: Record> = { const VERSION_NAMES: Record> = { 1: { 0: 'Latest', 1: 'V1 – Curve25519 / x25519' }, 2: { 0: 'Latest', 1: 'V1 – AES256-CBC + HMAC-SHA256', 2: 'V2 – XChaCha20-Poly1305' }, - 3: { 0: 'Latest', 1: 'V1 – PBKDF2-HMAC-SHA256' }, + 3: { 0: 'Latest', 1: 'V1 – PBKDF2-HMAC-SHA256', 2: 'V2 – Argon2id' }, 4: { 0: 'Latest', 1: 'V1 – Shamir Secret Sharing over GF256' }, 5: { 0: 'Latest', 1: 'V1 – Ed25519' }, 6: { 0: 'Latest', 1: 'V1 – Ed25519' }, @@ -191,7 +191,7 @@ export class InspectComponent implements OnInit { case 1: return this.parseKeyPayload(payload, subtype, abs); case 3: - return this.parsePasswordHashPayload(payload, abs); + return this.parsePasswordHashPayload(payload, version, abs); case 4: return this.parseSharePayload(payload, abs); case 8: @@ -368,17 +368,107 @@ export class InspectComponent implements OnInit { private parsePasswordHashPayload( payload: Uint8Array, + version: number, abs: (n: number) => number ): PayloadField[] { - return [ - { + const fields: PayloadField[] = []; + const dv = new DataView(payload.buffer, payload.byteOffset, payload.byteLength); + + if (version === 1) { + // V1: iterations(4 LE u32) + salt(32 bytes) + hash(32 bytes) = 68 bytes + if (payload.length < 68) { + fields.push({ + name: 'Error', + offset: abs(0), + size: payload.length, + hex: toHex(payload), + description: `Payload too short for V1 password hash (expected 68 bytes, got ${payload.length})`, + }); + return fields; + } + const iterations = dv.getUint32(0, true); + fields.push({ name: 'Iterations', offset: abs(0), size: 4, hex: toHex(payload.slice(0, 4)), description: `PBKDF2 iteration count: ${iterations.toLocaleString()}` }); + fields.push({ name: 'Salt', offset: abs(4), size: 32, hex: toHex(payload.slice(4, 36)), description: `PBKDF2 random salt (32 bytes)` }); + fields.push({ name: 'Hash', offset: abs(36), size: 32, hex: toHex(payload.slice(36, 68)), description: `PBKDF2-HMAC-SHA256 derived hash (32 bytes)` }); + } else if (version === 2) { + // V2: params_len(4 LE u32) + DerivationParameters(params_len bytes, includes inner 8-byte header) + hash(remaining) + if (payload.length < 4) { + fields.push({ + name: 'Error', + offset: abs(0), + size: payload.length, + hex: toHex(payload), + description: `Payload too short for V2 password hash (min 4 bytes, got ${payload.length})`, + }); + return fields; + } + const paramsLen = dv.getUint32(0, true); + fields.push({ + name: 'Parameters Length', + offset: abs(0), + size: 4, + hex: toHex(payload.slice(0, 4)), + description: `Serialized DerivationParameters size: ${paramsLen} bytes`, + }); + if (4 + paramsLen > payload.length) { + fields.push({ + name: 'Error', + offset: abs(4), + size: payload.length - 4, + hex: toHex(payload.slice(4)), + description: `Truncated: DerivationParameters claims ${paramsLen} bytes but only ${payload.length - 4} bytes remain`, + }); + return fields; + } + const dpBytes = payload.slice(4, 4 + paramsLen); + if (dpBytes.length >= 8) { + // Inner DerivationParameters has its own 8-byte header + const dpDv = new DataView(dpBytes.buffer, dpBytes.byteOffset, dpBytes.byteLength); + const dpDataType = dpDv.getUint16(2, true); + const dpVersion = dpDv.getUint16(6, true); + fields.push({ + name: 'DerivationParameters Header', + offset: abs(4), + size: 8, + hex: toHex(dpBytes.slice(0, 8)), + description: `Inner header — DataType: ${dpDataType} (KeyDerivation), Version: ${dpVersion}`, + }); + const dpPayload = dpBytes.slice(8); + const dpFields = this.parseDerivationParametersPayload( + dpPayload, + dpVersion, + (n) => abs(4 + 8 + n) + ); + fields.push(...dpFields); + } else { + fields.push({ + name: 'DerivationParameters', + offset: abs(4), + size: paramsLen, + hex: toHex(dpBytes), + description: `Serialized DerivationParameters (${paramsLen} bytes)`, + }); + } + const hashOffset = 4 + paramsLen; + const hashBytes = payload.slice(hashOffset); + fields.push({ + name: 'Hash', + offset: abs(hashOffset), + size: hashBytes.length, + hex: toHex(hashBytes), + description: `Argon2id derived hash (${hashBytes.length} bytes)`, + }); + } else { + fields.push({ name: 'Hash Data', offset: abs(0), size: payload.length, hex: toHex(payload, 32), description: `Password hash payload (${payload.length} bytes)`, - }, - ]; + }); + } + + return fields; } private parseSharePayload( diff --git a/wrappers/wasm/demo/src/app/password/password.component.html b/wrappers/wasm/demo/src/app/password/password.component.html index 448bc293..6f4b7975 100644 --- a/wrappers/wasm/demo/src/app/password/password.component.html +++ b/wrappers/wasm/demo/src/app/password/password.component.html @@ -20,8 +20,15 @@

Password Hashing

+ + +
+
- +
diff --git a/wrappers/wasm/demo/src/app/password/password.component.ts b/wrappers/wasm/demo/src/app/password/password.component.ts index 55ffaf12..a379a5ab 100644 --- a/wrappers/wasm/demo/src/app/password/password.component.ts +++ b/wrappers/wasm/demo/src/app/password/password.component.ts @@ -3,6 +3,7 @@ import { FormGroup, FormControl, ReactiveFormsModule } from '@angular/forms'; import { EncryptionService } from '../service/encryption.service'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { faKey } from '@fortawesome/free-solid-svg-icons'; +import { CommonModule } from '@angular/common'; import * as functions from '../shared/shared.component'; type EncryptionServiceInner = typeof import('../service/encryption.inner.service'); @@ -10,7 +11,7 @@ type EncryptionServiceInner = typeof import('../service/encryption.inner.service @Component({ selector: 'app-password', standalone: true, - imports: [ReactiveFormsModule, FaIconComponent], + imports: [ReactiveFormsModule, FaIconComponent, CommonModule], templateUrl: './password.component.html', }) export class PasswordComponent implements OnInit { @@ -27,7 +28,8 @@ export class PasswordComponent implements OnInit { this.passwordForm = new FormGroup({ password: new FormControl(''), - iteration: new FormControl(''), + algorithm: new FormControl('argon2'), + iterations: new FormControl('600000'), hashResult: new FormControl('') }); @@ -52,20 +54,23 @@ export class PasswordComponent implements OnInit { const service: EncryptionServiceInner = await this.encryptionService.innerModule; const pwd: string = this.passwordForm.value.password; - const iterString: string = this.passwordForm.value.iteration; if (pwd === null || pwd === '') { return; } const pwdArray: Uint8Array = this.encoder.encode(pwd); - const iter: number | undefined = (iterString === null || iterString === '') ? undefined : Number(iterString); + const algorithm: string = this.passwordForm.value.algorithm; + + let hash: Uint8Array; + if (algorithm === 'pbkdf2') { + const iterString: string = this.passwordForm.value.iterations; + const iter: number = (iterString === null || iterString === '') ? 600000 : Number(iterString); + const params: Uint8Array = service.getPbkdf2DerivationParameters(iter); + hash = service.hashPasswordWithParams(pwdArray, params); + } else { + hash = service.hashPassword(pwdArray); + } - const hash: Uint8Array = service.hashPassword(pwdArray, iter, service.PasswordHashVersion.Latest); const hashBase64: string = service.base64encode(hash); - - this.passwordForm.setValue({ - password: pwd, - iteration: iter, - hashResult: hashBase64 - }); + this.passwordForm.patchValue({ hashResult: hashBase64 }); } async verify() { diff --git a/wrappers/wasm/demo/src/app/service/encryption.inner.service.ts b/wrappers/wasm/demo/src/app/service/encryption.inner.service.ts index e75cbdd7..085a33e4 100644 --- a/wrappers/wasm/demo/src/app/service/encryption.inner.service.ts +++ b/wrappers/wasm/demo/src/app/service/encryption.inner.service.ts @@ -45,8 +45,20 @@ export function generateKey(length?: number): Uint8Array { return devolutionsCrypto.generateKey(length); } -export function hashPassword(password: Uint8Array, iterations?: number, version?: PasswordHashVersion): Uint8Array { - return devolutionsCrypto.hashPassword(password, iterations, version); +export function hashPassword(password: Uint8Array, version?: PasswordHashVersion): Uint8Array { + return devolutionsCrypto.hashPassword(password, version); +} + +export function hashPasswordWithParams(password: Uint8Array, params: Uint8Array): Uint8Array { + return devolutionsCrypto.hashPasswordWithParams(password, params); +} + +export function getArgon2DerivationParameters(parameters: Argon2Parameters): Uint8Array { + return devolutionsCrypto.getArgon2DerivationParameters(parameters); +} + +export function getPbkdf2DerivationParameters(iterations?: number): Uint8Array { + return devolutionsCrypto.getPbkdf2DerivationParameters(iterations); } export function verifyPassword(password: Uint8Array, hash: Uint8Array): boolean { diff --git a/wrappers/wasm/tests/tests/hashing.ts b/wrappers/wasm/tests/tests/hashing.ts index 72484444..4d85411f 100644 --- a/wrappers/wasm/tests/tests/hashing.ts +++ b/wrappers/wasm/tests/tests/hashing.ts @@ -1,21 +1,43 @@ -import { hashPassword, verifyPassword } from 'devolutions-crypto' +import { hashPassword, hashPasswordWithParams, verifyPassword, getPbkdf2DerivationParameters, getArgon2DerivationParameters, Argon2Parameters } from 'devolutions-crypto' import { describe, test } from 'node:test' import assert from 'node:assert/strict' const encoder: TextEncoder = new TextEncoder() describe('hashing', () => { - test('should validate the password', () => { - const hash: Uint8Array = hashPassword(encoder.encode('password'), 10) + test('should validate the password using hashPassword', () => { + const password = encoder.encode('password') + const hash: Uint8Array = hashPassword(password) + + assert.strictEqual(verifyPassword(password, hash), true) + assert.strictEqual(verifyPassword(encoder.encode('wrong'), hash), false) + }) + + test('should validate the password with custom PBKDF2 params (fast)', () => { + // Use PBKDF2 with low iterations for test speed + const params: Uint8Array = getPbkdf2DerivationParameters(10) + const hash: Uint8Array = hashPasswordWithParams(encoder.encode('password'), params) assert.strictEqual(verifyPassword(encoder.encode('password'), hash), true) }) - test('should not validate the password', () => { - const hash: Uint8Array = hashPassword(encoder.encode('password'), 10) + test('should not validate the password with invalid hash', () => { + const params: Uint8Array = getPbkdf2DerivationParameters(10) + const hash: Uint8Array = hashPasswordWithParams(encoder.encode('password'), params) assert.strictEqual(verifyPassword(encoder.encode('pa$$word'), hash), false) assert.strictEqual(verifyPassword(encoder.encode('Password'), hash), false) assert.strictEqual(verifyPassword(encoder.encode('password1'), hash), false) }) + + test('should validate the password with custom Argon2 params (memory=32, iterations=2)', () => { + const argon2Params = new Argon2Parameters() + argon2Params.memory = 32 + argon2Params.iterations = 2 + const params: Uint8Array = getArgon2DerivationParameters(argon2Params) + const hash: Uint8Array = hashPasswordWithParams(encoder.encode('password'), params) + + assert.strictEqual(verifyPassword(encoder.encode('password'), hash), true) + assert.strictEqual(verifyPassword(encoder.encode('wrong'), hash), false) + }) })