From 09579a909c425e63269197c017f843f4b7128610 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Duquette?= Date: Sat, 20 Jun 2026 19:23:18 -0400 Subject: [PATCH 1/3] [SEC-18095] Add an optional version parameter in HashPassword wrappers --- Cargo.lock | 12 ++--- Cargo.toml | 4 +- cli/Cargo.toml | 2 +- cli/src/main.rs | 2 +- ffi/Cargo.toml | 2 - ffi/src/lib.rs | 45 +++++++++++++------ python/pyproject.toml | 6 +-- wrappers/csharp/src/Enums.cs | 21 +++++++++ wrappers/csharp/src/Managed.cs | 7 +-- wrappers/csharp/src/Native.Core.cs | 4 +- .../csharp/tests/unit-tests/TestManaged.cs | 18 ++++++++ .../org/devolutions/crypto/HashingTest.kt | 18 ++++++++ .../HashingTests.swift | 16 +++++++ 13 files changed, 124 insertions(+), 33 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 47ae7f46..777696ad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -598,7 +598,7 @@ dependencies = [ [[package]] name = "devolutions-crypto" -version = "0.10.0" +version = "0.10.1" dependencies = [ "aead", "aes", @@ -637,7 +637,7 @@ dependencies = [ [[package]] name = "devolutions-crypto-cli" -version = "0.10.0" +version = "0.10.1" dependencies = [ "base64 0.11.0", "clap", @@ -646,7 +646,7 @@ dependencies = [ [[package]] name = "devolutions-crypto-ffi" -version = "0.10.0" +version = "0.10.1" dependencies = [ "base64 0.22.1", "devolutions-crypto", @@ -655,7 +655,7 @@ dependencies = [ [[package]] name = "devolutions-crypto-fuzz" -version = "0.10.0" +version = "0.10.1" dependencies = [ "arbitrary 1.4.2", "devolutions-crypto", @@ -664,14 +664,14 @@ dependencies = [ [[package]] name = "devolutions-crypto-python" -version = "0.10.0" +version = "0.10.1" dependencies = [ "uniffi", ] [[package]] name = "devolutions-crypto-uniffi" -version = "0.10.0" +version = "0.10.1" dependencies = [ "devolutions-crypto", "rust-argon2", diff --git a/Cargo.toml b/Cargo.toml index 8c57f183..cb7ec070 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -package.version = "0.10.0" +package.version = "0.10.1" members = [ "cli", "ffi", @@ -20,7 +20,7 @@ rust-argon2 = { version = "3.0", default-features = false } [package] name = "devolutions-crypto" version.workspace = true -authors = ["Philippe Dugre ", "Mathieu Morrissette "] +authors = ["Mathieu Morrissette ", "Sebastien Duquette "] edition = "2021" readme = "README_RUST.md" license = "MIT OR Apache-2.0" diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 295bcf4d..10121639 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "devolutions-crypto-cli" version.workspace = true -authors = ["Philippe Dugre "] +authors = ["Devolutions "] edition = "2018" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/cli/src/main.rs b/cli/src/main.rs index 8acbdd17..28feaa08 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -6,7 +6,7 @@ use std::{borrow::Borrow, convert::TryFrom}; #[derive(Debug, Parser)] #[command(name = "devolutions-crypto-cli")] #[command(version = env!("CARGO_PKG_VERSION"))] -#[command(author = "Philippe Dugre ")] +#[command(author = "Devolutions ")] struct Cli { #[command(subcommand)] command: Commands, diff --git a/ffi/Cargo.toml b/ffi/Cargo.toml index 25a32abb..fdd1abb4 100644 --- a/ffi/Cargo.toml +++ b/ffi/Cargo.toml @@ -7,8 +7,6 @@ edition = "2021" name = "devolutions_crypto_ffi" crate-type = ["cdylib", "staticlib"] -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html - [dependencies] devolutions-crypto = { path = "../" } zeroize = "1" diff --git a/ffi/src/lib.rs b/ffi/src/lib.rs index ca61f51e..befb271f 100644 --- a/ffi/src/lib.rs +++ b/ffi/src/lib.rs @@ -55,6 +55,10 @@ use zeroize::Zeroizing; const VERSION: &str = env!("CARGO_PKG_VERSION"); +pub const PASSWORD_HASH_LATEST: u16 = 0; +pub const PASSWORD_HASH_V1: u16 = 1; +pub const PASSWORD_HASH_V2: u16 = 1; + /// Encrypt a data blob /// # Arguments /// * `data` - Pointer to the data to encrypt. @@ -744,6 +748,7 @@ pub extern "C" fn SignSize(_version: u16) -> i64 { /// * `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. +/// * `version` - Version to use. Use PASSWORD_HASH_LATEST for the latest one. /// # Returns /// This returns the length of the hash. If there is an error, it will return the /// appropriate error code defined in DevoCryptoError. @@ -755,19 +760,25 @@ pub unsafe extern "C" fn HashPassword( password_length: usize, result: *mut u8, result_length: usize, + version: u16, ) -> i64 { if password.is_null() || result.is_null() { return Error::NullPointer.error_code(); }; - if result_length != HashPasswordLength() as usize { + let version = match PasswordHashVersion::try_from(version) { + Ok(v) => v, + Err(_) => return Error::UnknownVersion.error_code(), + }; + + if result_length != HashPasswordLength(version as u16) as usize { return Error::InvalidOutputLength.error_code(); }; 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, PasswordHashVersion::Latest) { + let res: Zeroizing> = match hash_password(password, version) { Ok(x) => Zeroizing::new(x.into()), Err(e) => return e.error_code(), }; @@ -778,17 +789,19 @@ pub unsafe extern "C" fn HashPassword( } /// Returns the length of the hash to input as `result_length` in `HashPassword()`. -/// The size reflects the default Argon2id parameters. +/// # Arguments +/// * `version` - Version to use. Use 0 for the latest one. /// # Returns -/// Returns the length of the hash. +/// Returns the length of the hash, or a negative error code for an unknown version. #[no_mangle] -pub extern "C" fn HashPasswordLength() -> i64 { - // 8 (PasswordHash header) - // + 4 (u32 params_len) - // + 8 (DerivationParameters header) - // + GetDefaultArgon2ParametersSize() (Argon2Parameters default) - // + 32 (Argon2 default output length) - 8 + 4 + 8 + GetDefaultArgon2ParametersSize() + 32 +pub extern "C" fn HashPasswordLength(version: u16) -> i64 { + match version { + // V1: PBKDF2 — fixed layout: 4 (iterations) + 32 (salt) + 32 (hash) = 68, plus 8-byte header + 1 => 8 + 68, + // V2 / Latest: Argon2id — header + params_len field + DerivationParameters + hash + 0 | 2 => 8 + 4 + 8 + GetDefaultArgon2ParametersSize() + 32, + _ => Error::UnknownVersion.error_code(), + } } /// Hash a password using caller-supplied serialized [`DerivationParameters`]. @@ -2419,8 +2432,14 @@ fn test_hash_password_length() { .unwrap() .into(); - assert_eq!(HashPasswordLength() as usize, small_password_hash.len()); - assert_eq!(HashPasswordLength() as usize, long_password_hash.len()); + assert_eq!( + HashPasswordLength(PASSWORD_HASH_LATEST) as usize, + small_password_hash.len() + ); + assert_eq!( + HashPasswordLength(PASSWORD_HASH_LATEST) as usize, + long_password_hash.len() + ); } #[test] diff --git a/python/pyproject.toml b/python/pyproject.toml index 295b86ce..cdcb0162 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -1,11 +1,11 @@ [project] name = "devolutions-crypto" -version = "2026.6.16" +version = "2026.6.22" description = "An abstraction layer for the cryptography used by Devolutions" readme = "PYPI_README.md" authors = [ - { name = "Philippe Dugre", email = "pdugre@devolutions.net" }, - { name = "Mathieu Morrissette", email = "mmorrissette@devolutions.net" } + { name = "Mathieu Morrissette", email = "mmorrissette@devolutions.net" }, + { name = "Sebastien Duquette", email = "sduquette@devolutions.net" }, ] license = { text = "MIT OR Apache-2.0" } requires-python = ">=3.10" diff --git a/wrappers/csharp/src/Enums.cs b/wrappers/csharp/src/Enums.cs index ec7b2bdd..34e35fc5 100644 --- a/wrappers/csharp/src/Enums.cs +++ b/wrappers/csharp/src/Enums.cs @@ -54,6 +54,27 @@ public enum DataType KdfEncryptedData = 9 } + /// + /// Devolutions Crypto Password Hash Version. + /// + public enum PasswordHashVersion + { + /// + /// This is the latest version. (Currently Argon2id) + /// + Latest = 0, + + /// + /// PBKDF2-HMAC-SHA256. + /// + V1 = 1, + + /// + /// Argon2id. + /// + V2 = 2, + } + /// /// Devolutions Crypto Cipher Version. /// diff --git a/wrappers/csharp/src/Managed.cs b/wrappers/csharp/src/Managed.cs index 148ca25a..5c554baf 100644 --- a/wrappers/csharp/src/Managed.cs +++ b/wrappers/csharp/src/Managed.cs @@ -674,15 +674,16 @@ public static bool VerifyPassword(byte[] password, byte[] hash, ILegacyHasher? l /// Use to supply custom . /// /// The password to hash in bytes. + /// The version of the password hashing algorithm to use. Defaults to . /// Returns the hashed password in bytes. - public static byte[] HashPassword(byte[] password) + public static byte[] HashPassword(byte[] password, PasswordHashVersion version = PasswordHashVersion.Latest) { if (password == null || password.Length == 0) { throw new DevolutionsCryptoException(ManagedError.InvalidParameter); } - long hashLength = Native.HashPasswordLengthNative(); + long hashLength = Native.HashPasswordLengthNative((ushort)version); if (hashLength < 0) { @@ -690,7 +691,7 @@ public static byte[] HashPassword(byte[] password) } byte[] result = new byte[hashLength]; - long res = Native.HashPasswordNative(password, (UIntPtr)password.Length, result, (UIntPtr)result.Length); + long res = Native.HashPasswordNative(password, (UIntPtr)password.Length, result, (UIntPtr)result.Length, (ushort)version); if (res < 0) { diff --git a/wrappers/csharp/src/Native.Core.cs b/wrappers/csharp/src/Native.Core.cs index d8a8cd38..aaa19b86 100644 --- a/wrappers/csharp/src/Native.Core.cs +++ b/wrappers/csharp/src/Native.Core.cs @@ -119,10 +119,10 @@ public static partial class Native internal static extern long GetDefaultArgon2ParametersSizeNative(); [DllImport(LibName, EntryPoint = "HashPasswordLength", CallingConvention = CallingConvention.Cdecl)] - internal static extern long HashPasswordLengthNative(); + internal static extern long HashPasswordLengthNative(ushort version); [DllImport(LibName, EntryPoint = "HashPassword", CallingConvention = CallingConvention.Cdecl)] - internal static extern long HashPasswordNative(byte[] password, UIntPtr passwordLength, byte[] result, UIntPtr resultLength); + internal static extern long HashPasswordNative(byte[] password, UIntPtr passwordLength, byte[] result, UIntPtr resultLength, ushort version); [DllImport(LibName, EntryPoint = "HashPasswordWithParamsLength", CallingConvention = CallingConvention.Cdecl)] internal static extern long HashPasswordWithParamsLengthNative(byte[] derivationParams, UIntPtr derivationParamsLength); diff --git a/wrappers/csharp/tests/unit-tests/TestManaged.cs b/wrappers/csharp/tests/unit-tests/TestManaged.cs index 1e7b8e58..507aedba 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 HashPasswordV1() + { + byte[] hash = Managed.HashPassword(TestData.BytesTestKey, PasswordHashVersion.V1); + + Assert.IsTrue(Managed.VerifyPassword(TestData.BytesTestKey, hash)); + Assert.IsFalse(Managed.VerifyPassword(TestData.BytesTestData, hash)); + } + + [TestMethod] + public void HashPasswordV2() + { + byte[] hash = Managed.HashPassword(TestData.BytesTestKey, PasswordHashVersion.V2); + + Assert.IsTrue(Managed.VerifyPassword(TestData.BytesTestKey, hash)); + Assert.IsFalse(Managed.VerifyPassword(TestData.BytesTestData, hash)); + } + [TestMethod] public void HashPasswordWithParams() { 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 8a17d241..98d1bc58 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 @@ -23,4 +23,22 @@ class HashingTest { assert(!verifyPassword("Password".toByteArray(), hash)) assert(!verifyPassword("password1".toByteArray(), hash)) } + + @Test + fun passwordHashV1Test() { + val password = "password".toByteArray(Charsets.UTF_8) + val hash = hashPassword(password, version = PasswordHashVersion.V1) + + assert(verifyPassword(password, hash)) + assert(!verifyPassword("wrongpassword".toByteArray(), hash)) + } + + @Test + fun passwordHashV2Test() { + val password = "password".toByteArray(Charsets.UTF_8) + val hash = hashPassword(password, version = PasswordHashVersion.V2) + + assert(verifyPassword(password, hash)) + assert(!verifyPassword("wrongpassword".toByteArray(), hash)) + } } \ No newline at end of file diff --git a/wrappers/swift/DevolutionsCryptoSwift/Tests/DevolutionsCryptoSwiftTests/HashingTests.swift b/wrappers/swift/DevolutionsCryptoSwift/Tests/DevolutionsCryptoSwiftTests/HashingTests.swift index a008896b..8712cfdb 100644 --- a/wrappers/swift/DevolutionsCryptoSwift/Tests/DevolutionsCryptoSwiftTests/HashingTests.swift +++ b/wrappers/swift/DevolutionsCryptoSwift/Tests/DevolutionsCryptoSwiftTests/HashingTests.swift @@ -17,4 +17,20 @@ class HashingTests: XCTestCase { XCTAssertFalse(try verifyPassword(password: Data("Password".utf8), hash: hash)) XCTAssertFalse(try verifyPassword(password: Data("password1".utf8), hash: hash)) } + + func testPasswordHashV1() throws { + let password = Data("password".utf8) + let hash = try hashPassword(password: password, version: PasswordHashVersion.v1) + + XCTAssertTrue(try verifyPassword(password: password, hash: hash)) + XCTAssertFalse(try verifyPassword(password: Data("wrongpassword".utf8), hash: hash)) + } + + func testPasswordHashV2() throws { + let password = Data("password".utf8) + let hash = try hashPassword(password: password, version: PasswordHashVersion.v2) + + XCTAssertTrue(try verifyPassword(password: password, hash: hash)) + XCTAssertFalse(try verifyPassword(password: Data("wrongpassword".utf8), hash: hash)) + } } From 03361ffe6914ceffdbdd6156a8846efd7e3c7b48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Duquette?= Date: Mon, 22 Jun 2026 09:51:10 -0400 Subject: [PATCH 2/3] copilot review --- ffi/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ffi/src/lib.rs b/ffi/src/lib.rs index befb271f..7cb7bcda 100644 --- a/ffi/src/lib.rs +++ b/ffi/src/lib.rs @@ -57,7 +57,7 @@ const VERSION: &str = env!("CARGO_PKG_VERSION"); pub const PASSWORD_HASH_LATEST: u16 = 0; pub const PASSWORD_HASH_V1: u16 = 1; -pub const PASSWORD_HASH_V2: u16 = 1; +pub const PASSWORD_HASH_V2: u16 = 2; /// Encrypt a data blob /// # Arguments From dcc75739a466d148100086eb1d1b34bc2b60664e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Duquette?= Date: Mon, 22 Jun 2026 09:55:07 -0400 Subject: [PATCH 3/3] use constants --- ffi/src/lib.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/ffi/src/lib.rs b/ffi/src/lib.rs index 7cb7bcda..2dc2dfe3 100644 --- a/ffi/src/lib.rs +++ b/ffi/src/lib.rs @@ -747,7 +747,7 @@ pub extern "C" fn SignSize(_version: u16) -> i64 { /// * `password_length` - Length of the password to hash. /// * `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. +/// calling HashPasswordLength(version) beforehand. /// * `version` - Version to use. Use PASSWORD_HASH_LATEST for the latest one. /// # Returns /// This returns the length of the hash. If there is an error, it will return the @@ -797,9 +797,11 @@ pub unsafe extern "C" fn HashPassword( pub extern "C" fn HashPasswordLength(version: u16) -> i64 { match version { // V1: PBKDF2 — fixed layout: 4 (iterations) + 32 (salt) + 32 (hash) = 68, plus 8-byte header - 1 => 8 + 68, + PASSWORD_HASH_V1 => 8 + 68, // V2 / Latest: Argon2id — header + params_len field + DerivationParameters + hash - 0 | 2 => 8 + 4 + 8 + GetDefaultArgon2ParametersSize() + 32, + PASSWORD_HASH_LATEST | PASSWORD_HASH_V2 => { + 8 + 4 + 8 + GetDefaultArgon2ParametersSize() + 32 + } _ => Error::UnknownVersion.error_code(), } }