From f2d2445835b0210348c09f032f525537db148b34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Duquette?= Date: Mon, 1 Jun 2026 20:00:37 -0400 Subject: [PATCH 1/7] feat(derive_encrypt): Add KdfEncryptedData type KdfEncryptData combines two operations, key derivation and encryption, to generate a blob that contains both the key derivation parameters and the encrypted data. This way the derivation parameters do not need to be stored separately or defined statically in the application. --- cli/src/main.rs | 72 +++++ ffi/src/lib.rs | 171 +++++++++++ python/devolutions_crypto.pyi | 60 ++++ python/src/lib.rs | 46 ++- src/derive_encrypt/kdf_encrypted_data_v1.rs | 74 +++++ src/derive_encrypt/mod.rs | 290 ++++++++++++++++++ src/enums.rs | 25 ++ src/key_derivation/key_derivation_v2.rs | 87 +++++- src/key_derivation/mod.rs | 13 +- src/lib.rs | 39 ++- src/utils.rs | 2 + .../src/derive_encrypt.rs | 61 ++++ uniffi/devolutions-crypto-uniffi/src/lib.rs | 14 +- 13 files changed, 946 insertions(+), 8 deletions(-) create mode 100644 src/derive_encrypt/kdf_encrypted_data_v1.rs create mode 100644 src/derive_encrypt/mod.rs create mode 100644 uniffi/devolutions-crypto-uniffi/src/derive_encrypt.rs diff --git a/cli/src/main.rs b/cli/src/main.rs index c3e24516..6e9946ad 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -158,6 +158,30 @@ enum Commands { shares: Vec, }, + /// Combine key derivation and encryption in a single operation + #[command(arg_required_else_help = true)] + DeriveAndEncrypt { + /// The plaintext to encrypt + data: String, + + /// The password from which the encryption key is derived + password: String, + + /// The cipher version to use (default: latest) + #[arg(short, long)] + version: Option, + }, + + /// Decrypt a KdfEncryptedData blob with a password + #[command(arg_required_else_help = true)] + DeriveAndDecrypt { + /// The KdfEncryptedData blob to decrypt, in base64 + data: String, + + /// The password used during encryption + password: String, + }, + /// Print the header information #[command(arg_required_else_help = true)] PrintHeader { @@ -198,6 +222,8 @@ fn main() { Commands::VerifyPassword { password, hash } => verify_password(hash, password), Commands::MixKeyExchange { private, public } => mix_key_exchange(private, public), Commands::JoinShares { shares } => join_shares(shares), + Commands::DeriveAndEncrypt { data, password, version } => derive_and_encrypt_password(data, password, version), + Commands::DeriveAndDecrypt { data, password } => derive_and_decrypt_password(data, password), Commands::PrintHeader { data } => print_header(data), } } @@ -258,6 +284,41 @@ fn derive_key(data: String, salt: Option, iterations: Option) { println!("DerivationParameters: {}", base64::encode(¶ms_bytes)); } +fn derive_and_encrypt_password(data: String, password: String, version: Option) { + use devolutions_crypto::derive_encrypt::encrypt_with_password; + use devolutions_crypto::key_derivation::Argon2; + + let version = version.unwrap_or(0); + let version = devolutions_crypto::CiphertextVersion::try_from(version).unwrap(); + + let params = Argon2::new().parameters(); + let result: Vec = encrypt_with_password(data.as_bytes(), password.as_bytes(), params, version) + .unwrap() + .into(); + println!("{}", base64::encode(&result)); +} + +fn derive_and_decrypt_password(data: String, password: String) { + use devolutions_crypto::derive_encrypt::KdfEncryptedData; + + let data_bytes = decode_base64_arg("data", &data); + let blob = KdfEncryptedData::try_from(data_bytes.as_slice()).unwrap_or_else(|_| { + eprintln!( + "Error: 'data' - expected KdfEncryptedData, received {}.", + detect_dc_type(&data_bytes) + ); + std::process::exit(1); + }); + + let result: Vec = blob + .decrypt_with_password(password.as_bytes()) + .unwrap_or_else(|e| { + eprintln!("Error: decryption failed: {}.", e); + std::process::exit(1); + }); + println!("{}", String::from_utf8_lossy(&result)); +} + /// Returns a human-readable description of a managed type by inspecting its header. /// Returns "unknown" if the bytes are too short or contain an unrecognized type. fn detect_dc_type(bytes: &[u8]) -> String { @@ -284,6 +345,7 @@ fn detect_dc_type(bytes: &[u8]) -> String { Ok(DataType::SigningKey) => "SigningKey".to_string(), Ok(DataType::Signature) => "Signature".to_string(), Ok(DataType::KeyDerivation) => "DerivationParameters".to_string(), + Ok(DataType::KdfEncryptedData) => "KdfEncryptedData".to_string(), _ => "unknown".to_string(), } } @@ -541,6 +603,16 @@ fn print_header(data: String) { println!("Invalid Header"); } } + Ok(devolutions_crypto::DataType::KdfEncryptedData) => { + if let Ok(header) = devolutions_crypto::Header::< + devolutions_crypto::derive_encrypt::KdfEncryptedData, + >::try_from(&data[0..8]) + { + println!("{:?}", &header); + } else { + println!("Invalid Header"); + } + } _ => println!("Invalid Header"), } } diff --git a/ffi/src/lib.rs b/ffi/src/lib.rs index 4b42f936..0368b5d8 100644 --- a/ffi/src/lib.rs +++ b/ffi/src/lib.rs @@ -21,6 +21,7 @@ use base64::{engine::general_purpose, Engine as _}; use devolutions_crypto::ciphertext::{ encrypt_asymmetric_with_aad, encrypt_with_aad, Ciphertext, CiphertextVersion, }; +use devolutions_crypto::derive_encrypt::{encrypt_with_password_and_aad, KdfEncryptedData}; use devolutions_crypto::key::{ generate_keypair, generate_secret_key, mix_key_exchange, KeyVersion, PrivateKey, PublicKey, }; @@ -32,6 +33,7 @@ use devolutions_crypto::secret_sharing::{ generate_shared_key, join_shares, SecretSharingVersion, Share, }; use devolutions_crypto::OnlineCiphertextVersion; +use devolutions_crypto::KeyDerivationVersion; use devolutions_crypto::{ signature, signature::{Signature, SignatureVersion}, @@ -221,6 +223,175 @@ pub extern "C" fn EncryptAsymmetricSize(data_length: usize, version: u16) -> i64 } } +/// Get the size of the resulting derive_encrypt blob. +/// # Arguments +/// * data_length - Length of the plaintext. +/// * key_derivation_version - Version for key derivation (0 latest, 1 PBKDF2, 2 Argon2). +/// * ciphertext_version - Version for ciphertext (0 latest, 1 V1, 2 V2). +/// # Returns +/// Returns the exact output length expected by `DeriveEncryptData()`. +#[no_mangle] +pub extern "C" fn DeriveEncryptSize( + data_length: usize, + key_derivation_version: u16, + ciphertext_version: u16, +) -> i64 { + let key_derivation_version = match KeyDerivationVersion::try_from(key_derivation_version) { + Ok(v) => v, + Err(_) => return Error::UnknownVersion.error_code(), + }; + + let ciphertext_size = EncryptSize(data_length, ciphertext_version); + + if ciphertext_size < 0 { + return ciphertext_size; + } + + let derivation_parameters_size: usize = match key_derivation_version { + KeyDerivationVersion::V1 => DeriveSecretKeyPbkdf2Size() as usize, + KeyDerivationVersion::V2 | KeyDerivationVersion::Latest => { + DeriveSecretKeyArgon2ParametersSize(GetDefaultArgon2ParametersSize() as usize) as usize + } + }; + + (8 + 4 + derivation_parameters_size + 4 + ciphertext_size as usize) as i64 +} + +/// Derive a key from a password and encrypt data in one managed blob. +/// # Arguments +/// * `data` - Pointer to the plaintext data to encrypt. +/// * `data_length` - Length of plaintext data. +/// * `password` - Pointer to the password bytes. +/// * `password_length` - Length of password bytes. +/// * `aad` - Pointer to additional authenticated data, or null. +/// * `aad_length` - Length of additional authenticated data. +/// * `result` - Pointer to output buffer. +/// * `result_length` - Length of output buffer, must equal `DeriveEncryptSize(...)`. +/// * `key_derivation_version` - Key derivation version. +/// * `ciphertext_version` - Ciphertext version. +/// # Returns +/// The number of bytes written on success, or a negative DevoCryptoError code on failure. +/// # Safety +/// This method is made to be called by C, so it is therefore unsafe. The caller should make sure it passes the right pointers and sizes. +#[no_mangle] +pub unsafe extern "C" fn DeriveEncryptData( + data: *const u8, + data_length: usize, + password: *const u8, + password_length: usize, + aad: *const u8, + aad_length: usize, + result: *mut u8, + result_length: usize, + key_derivation_version: u16, + ciphertext_version: u16, +) -> i64 { + if data.is_null() || password.is_null() || result.is_null() { + return Error::NullPointer.error_code(); + } + + if result_length != DeriveEncryptSize(data_length, key_derivation_version, ciphertext_version) as usize { + return Error::InvalidOutputLength.error_code(); + } + + let key_derivation_version = match KeyDerivationVersion::try_from(key_derivation_version) { + Ok(v) => v, + Err(_) => return Error::UnknownVersion.error_code(), + }; + + let ciphertext_version = match CiphertextVersion::try_from(ciphertext_version) { + Ok(v) => v, + Err(_) => return Error::UnknownVersion.error_code(), + }; + + let aad = if aad.is_null() { + &[] + } else { + slice::from_raw_parts(aad, aad_length) + }; + + let data = slice::from_raw_parts(data, data_length); + let password = Zeroizing::new(slice::from_raw_parts(password, password_length).to_vec()); + let result = slice::from_raw_parts_mut(result, result_length); + + let derivation_parameters = match derive_key(&password, key_derivation_version) { + Ok((_, params)) => params, + Err(e) => return e.error_code(), + }; + + match encrypt_with_password_and_aad( + data, + &password, + aad, + derivation_parameters, + ciphertext_version, + ) { + Ok(res) => { + let res: Vec = res.into(); + let length = res.len(); + result[0..length].copy_from_slice(&res); + length as i64 + } + Err(e) => e.error_code(), + } +} + +/// Decrypt a derive_encrypt blob using a password. +/// # Arguments +/// * `data` - Pointer to derive_encrypt bytes. +/// * `data_length` - Length of derive_encrypt bytes. +/// * `password` - Pointer to password bytes. +/// * `password_length` - Length of password bytes. +/// * `aad` - Pointer to additional authenticated data, or null. +/// * `aad_length` - Length of additional authenticated data. +/// * `result` - Pointer to output plaintext buffer. +/// * `result_length` - Length of output plaintext buffer. +/// # Returns +/// The number of plaintext bytes written on success, or a negative DevoCryptoError code on failure. +/// # Safety +/// This method is made to be called by C, so it is therefore unsafe. The caller should make sure it passes the right pointers and sizes. +#[no_mangle] +pub unsafe extern "C" fn DeriveDecryptData( + data: *const u8, + data_length: usize, + password: *const u8, + password_length: usize, + aad: *const u8, + aad_length: usize, + result: *mut u8, + result_length: usize, +) -> i64 { + if data.is_null() || password.is_null() || result.is_null() { + return Error::NullPointer.error_code(); + } + + let aad = if aad.is_null() { + &[] + } else { + slice::from_raw_parts(aad, aad_length) + }; + + let data = slice::from_raw_parts(data, data_length); + let password = Zeroizing::new(slice::from_raw_parts(password, password_length).to_vec()); + let result = slice::from_raw_parts_mut(result, result_length); + + match KdfEncryptedData::try_from(data) { + Ok(res) => match res.decrypt_with_password_and_aad(&password, aad) { + Ok(plaintext) => { + if result.len() >= plaintext.len() { + let length = plaintext.len(); + result[0..length].copy_from_slice(&plaintext); + length as i64 + } else { + Error::InvalidOutputLength.error_code() + } + } + Err(e) => e.error_code(), + }, + Err(e) => e.error_code(), + } +} + /// Decrypt a data blob /// # Arguments /// * `data` - Pointer to the data to decrypt. diff --git a/python/devolutions_crypto.pyi b/python/devolutions_crypto.pyi index 925abb55..93c20d61 100644 --- a/python/devolutions_crypto.pyi +++ b/python/devolutions_crypto.pyi @@ -394,6 +394,64 @@ def decrypt_with_secret_key( """ ... +def derive_encrypt_with_password( + data: bytes, + password: bytes, + aad: Optional[bytes] = None, + key_derivation_version: int = 0, + ciphertext_version: int = 0 +) -> bytes: + """ + Derive a key from a password and encrypt data in a single blob. + + The output contains the key derivation parameters and the ciphertext, + allowing decryption with only the password. + + Args: + data: The plaintext data to encrypt + password: The password to derive the encryption key from + aad: Optional Additional Authenticated Data for AEAD + key_derivation_version: KDF version (0 = latest/Argon2, 1 = PBKDF2) + ciphertext_version: Ciphertext version (0 = latest) + + Returns: + The DeriveEncrypt blob as bytes (KDF params + ciphertext) + + Raises: + DevolutionsCryptoException: If encryption or key derivation fails + + Example: + >>> blob = derive_encrypt_with_password(b'secret data', b'my password') + """ + ... + +def derive_decrypt_with_password( + data: bytes, + password: bytes, + aad: Optional[bytes] = None +) -> bytes: + """ + Decrypt a DeriveEncrypt blob using the original password. + + Re-derives the encryption key from the embedded KDF parameters and password, + then decrypts the embedded ciphertext. + + Args: + data: The DeriveEncrypt blob (produced by derive_encrypt_with_password) + password: The password used during encryption + aad: Optional Additional Authenticated Data (must match encryption AAD) + + Returns: + The decrypted plaintext as bytes + + Raises: + DevolutionsCryptoException: If decryption fails, wrong password, or invalid blob + + Example: + >>> plaintext = derive_decrypt_with_password(blob, b'my password') + """ + ... + def get_signing_public_key(keypair: bytes) -> bytes: """ Extract the public key from a signing keypair. @@ -481,4 +539,6 @@ __all__ = [ 'generate_secret_key', 'encrypt_with_secret_key', 'decrypt_with_secret_key', + 'derive_encrypt_with_password', + 'derive_decrypt_with_password', ] diff --git a/python/src/lib.rs b/python/src/lib.rs index cade0f13..07342661 100644 --- a/python/src/lib.rs +++ b/python/src/lib.rs @@ -17,7 +17,8 @@ use devolutions_crypto::{ signing_key, signing_key::{SigningKeyPair, SigningPublicKey}, }; -use devolutions_crypto::{CiphertextVersion, KeyVersion, SignatureVersion, SigningKeyVersion}; +use devolutions_crypto::{CiphertextVersion, KeyDerivationVersion, KeyVersion, SignatureVersion, SigningKeyVersion}; +use devolutions_crypto::{derive_encrypt, derive_encrypt::KdfEncryptedData}; enum DevolutionsCryptoError { DevolutionsCrypto(Error), @@ -345,6 +346,47 @@ fn generate_signing_keypair(py: Python, version: u16) -> Result> { Ok(PyBytes::new(py, &kp).into()) } +#[pyfunction] +#[pyo3(name = "derive_encrypt_with_password")] +#[pyo3(signature = (data, password, aad=None, key_derivation_version=0, ciphertext_version=0))] +fn derive_encrypt_with_password( + py: Python, + data: &[u8], + password: &[u8], + aad: Option<&[u8]>, + key_derivation_version: u16, + ciphertext_version: u16, +) -> Result> { + let kdf_version = match KeyDerivationVersion::try_from(key_derivation_version) { + Ok(v) => v, + Err(_) => return Err(Error::UnknownVersion.into()), + }; + let ct_version = match CiphertextVersion::try_from(ciphertext_version) { + Ok(v) => v, + Err(_) => return Err(Error::UnknownVersion.into()), + }; + let aad = aad.unwrap_or(&[]); + let (_, params) = devolutions_crypto::key_derivation::derive_key(password, kdf_version)?; + let result: Vec = + derive_encrypt::encrypt_with_password_and_aad(data, password, aad, params, ct_version)?.into(); + Ok(PyBytes::new(py, &result).into()) +} + +#[pyfunction] +#[pyo3(name = "derive_decrypt_with_password")] +#[pyo3(signature = (data, password, aad=None))] +fn derive_decrypt_with_password( + py: Python, + data: &[u8], + password: &[u8], + aad: Option<&[u8]>, +) -> Result> { + let aad = aad.unwrap_or(&[]); + let blob = KdfEncryptedData::try_from(data)?; + let plaintext = blob.decrypt_with_password_and_aad(password, aad)?; + Ok(PyBytes::new(py, &plaintext).into()) +} + #[pyfunction] #[pyo3(name = "get_signing_public_key")] fn get_signing_public_key(py: Python, keypair: &[u8]) -> Result> { @@ -377,6 +419,8 @@ fn devolutions_crypto_module(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(generate_secret_key, m)?)?; m.add_function(wrap_pyfunction!(encrypt_with_secret_key, m)?)?; m.add_function(wrap_pyfunction!(decrypt_with_secret_key, m)?)?; + m.add_function(wrap_pyfunction!(derive_encrypt_with_password, m)?)?; + m.add_function(wrap_pyfunction!(derive_decrypt_with_password, m)?)?; m.add_class::()?; m.add( "DevolutionsCryptoException", diff --git a/src/derive_encrypt/kdf_encrypted_data_v1.rs b/src/derive_encrypt/kdf_encrypted_data_v1.rs new file mode 100644 index 00000000..2b49c52c --- /dev/null +++ b/src/derive_encrypt/kdf_encrypted_data_v1.rs @@ -0,0 +1,74 @@ +use std::convert::TryFrom; +use std::io::{Cursor, Read, Write}; + +use byteorder::{LittleEndian, ReadBytesExt, WriteBytesExt}; + +use crate::ciphertext::Ciphertext; +use crate::key_derivation::DerivationParameters; +use crate::{Error, Result}; + +#[cfg(feature = "fuzz")] +use arbitrary::Arbitrary; + +#[derive(Clone, Debug)] +#[cfg_attr(feature = "fuzz", derive(Arbitrary))] +pub struct KdfEncryptedDataV1 { + pub derivation_parameters: DerivationParameters, + pub ciphertext: Ciphertext, +} + +impl From<&KdfEncryptedDataV1> for Vec { + fn from(data: &KdfEncryptedDataV1) -> Self { + let derivation_parameters: Vec = data.derivation_parameters.clone().into(); + let ciphertext: Vec = data.ciphertext.clone().into(); + + let mut serialized = + Vec::with_capacity(8 + derivation_parameters.len() + ciphertext.len()); + + serialized + .write_u32::(derivation_parameters.len() as u32) + .unwrap(); + serialized + .write_u32::(ciphertext.len() as u32) + .unwrap(); + serialized.write_all(&derivation_parameters).unwrap(); + serialized.write_all(&ciphertext).unwrap(); + + serialized + } +} + +impl TryFrom<&[u8]> for KdfEncryptedDataV1 { + type Error = Error; + + fn try_from(data: &[u8]) -> Result { + if data.len() < 8 { + return Err(Error::InvalidLength); + } + + let mut cursor = Cursor::new(data); + + let derivation_parameters_length = cursor.read_u32::()? as usize; + let ciphertext_length = cursor.read_u32::()? as usize; + + let total_expected = 8usize + .checked_add(derivation_parameters_length) + .and_then(|x| x.checked_add(ciphertext_length)) + .ok_or(Error::InvalidLength)?; + + if data.len() != total_expected { + return Err(Error::InvalidLength); + } + + let mut derivation_parameters_raw = vec![0u8; derivation_parameters_length]; + cursor.read_exact(&mut derivation_parameters_raw)?; + + let mut ciphertext_raw = vec![0u8; ciphertext_length]; + cursor.read_exact(&mut ciphertext_raw)?; + + Ok(Self { + derivation_parameters: DerivationParameters::try_from(derivation_parameters_raw.as_slice())?, + ciphertext: Ciphertext::try_from(ciphertext_raw.as_slice())?, + }) + } +} diff --git a/src/derive_encrypt/mod.rs b/src/derive_encrypt/mod.rs new file mode 100644 index 00000000..c40425d1 --- /dev/null +++ b/src/derive_encrypt/mod.rs @@ -0,0 +1,290 @@ +mod kdf_encrypted_data_v1; + +use std::borrow::Borrow; +use std::convert::TryFrom; + +use crate::ciphertext; +use crate::enums::KdfEncryptedDataSubtype; +use crate::key_derivation::DerivationParameters; +use crate::{ + CiphertextVersion, DataType, KdfEncryptedDataVersion, Error, Header, HeaderType, Result, +}; + +use kdf_encrypted_data_v1::KdfEncryptedDataV1; + +#[cfg(feature = "fuzz")] +use arbitrary::Arbitrary; + +/// A blob that stores key derivation parameters alongside a symmetric ciphertext, +/// allowing decryption with only the original password. +/// +/// The serialized format contains the [`DerivationParameters`](crate::key_derivation::DerivationParameters) +/// used to derive the key and the resulting [`Ciphertext`](crate::ciphertext::Ciphertext), +/// so no external state is required to decrypt. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "fuzz", derive(Arbitrary))] +pub struct KdfEncryptedData { + pub(crate) header: Header, + payload: KdfEncryptedDataPayload, +} + +impl HeaderType for KdfEncryptedData { + type Version = KdfEncryptedDataVersion; + type Subtype = KdfEncryptedDataSubtype; + + fn data_type() -> DataType { + DataType::KdfEncryptedData + } +} + +#[derive(Clone, Debug)] +#[cfg_attr(feature = "fuzz", derive(Arbitrary))] +enum KdfEncryptedDataPayload { + V1(KdfEncryptedDataV1), +} + +/// Encrypts `data` with a key derived from `password` and returns a self-contained blob. +/// +/// Equivalent to calling [`encrypt_with_password_and_aad`] with an empty AAD. +/// +/// # Arguments +/// * `data` - The plaintext data to encrypt. +/// * `password` - The password from which the encryption key is derived. +/// * `derivation_parameters` - Pre-built key derivation parameters (includes the salt). Use +/// [`Argon2::new().derive(password)`](crate::key_derivation::Argon2::derive) to generate them. +/// * `ciphertext_version` - Cipher to use. `CiphertextVersion::Latest` is recommended. +/// # Returns +/// Returns a [`KdfEncryptedData`] blob containing the key derivation parameters and the ciphertext. +/// # Example +/// ```rust +/// use devolutions_crypto::derive_encrypt::encrypt_with_password; +/// use devolutions_crypto::key_derivation::Argon2; +/// use devolutions_crypto::CiphertextVersion; +/// +/// let (_, params) = Argon2::new().derive(b"password").unwrap(); +/// let blob = encrypt_with_password(b"secret", b"password", params, CiphertextVersion::Latest).unwrap(); +/// let plaintext = blob.decrypt_with_password(b"password").unwrap(); +/// assert_eq!(plaintext, b"secret"); +/// ``` +pub fn encrypt_with_password( + data: &[u8], + password: &[u8], + derivation_parameters: DerivationParameters, + ciphertext_version: CiphertextVersion, +) -> Result { + encrypt_with_password_and_aad(data, password, [].as_slice(), derivation_parameters, ciphertext_version) +} + +/// Encrypts `data` with a key derived from `password` and authenticates `aad`. +/// +/// # Arguments +/// * `data` - The plaintext data to encrypt. +/// * `password` - The password from which the encryption key is derived. +/// * `aad` - Additional Authenticated Data bound to the ciphertext; must be provided unchanged on decryption. +/// * `derivation_parameters` - Pre-built key derivation parameters (includes the salt). Use +/// [`Argon2::new().derive(password)`](crate::key_derivation::Argon2::derive) to generate them. +/// * `ciphertext_version` - Cipher to use. `CiphertextVersion::Latest` is recommended. +/// # Returns +/// Returns a [`KdfEncryptedData`] blob containing the key derivation parameters and the ciphertext. +pub fn encrypt_with_password_and_aad( + data: &[u8], + password: &[u8], + aad: &[u8], + derivation_parameters: DerivationParameters, + ciphertext_version: CiphertextVersion, +) -> Result { + let mut header: Header = Header::default(); + header.version = KdfEncryptedDataVersion::V1; + + let secret_key = derivation_parameters.derive(password)?; + let ciphertext = ciphertext::encrypt_with_secret_key_and_aad( + data, + &secret_key, + aad, + ciphertext_version, + )?; + + Ok(KdfEncryptedData { + header, + payload: KdfEncryptedDataPayload::V1(KdfEncryptedDataV1 { + derivation_parameters, + ciphertext, + }), + }) +} + +impl KdfEncryptedData { + /// Decrypts this blob using `password`. + /// + /// Equivalent to calling [`decrypt_with_password_and_aad`](Self::decrypt_with_password_and_aad) with an empty AAD. + /// + /// # Arguments + /// * `password` - The password used during encryption. + /// # Returns + /// Returns the decrypted plaintext, or an error if the password is wrong or the blob is invalid. + pub fn decrypt_with_password(&self, password: &[u8]) -> Result> { + self.decrypt_with_password_and_aad(password, [].as_slice()) + } + + /// Decrypts this blob using `password`, verifying `aad`. + /// + /// # Arguments + /// * `password` - The password used during encryption. + /// * `aad` - The same Additional Authenticated Data that was provided during encryption. + /// # Returns + /// Returns the decrypted plaintext, or an error if the password is wrong, the AAD does not match, + /// or the blob is invalid. + pub fn decrypt_with_password_and_aad(&self, password: &[u8], aad: &[u8]) -> Result> { + match &self.payload { + KdfEncryptedDataPayload::V1(payload) => { + let secret_key = payload.derivation_parameters.derive(password)?; + payload + .ciphertext + .decrypt_with_secret_key_and_aad(&secret_key, aad) + } + } + } +} + +impl From for Vec { + fn from(data: KdfEncryptedData) -> Self { + let mut header: Self = data.header.borrow().into(); + let mut payload: Self = data.payload.into(); + header.append(&mut payload); + header + } +} + +impl From for Vec { + fn from(data: KdfEncryptedDataPayload) -> Self { + match data { + KdfEncryptedDataPayload::V1(v1) => v1.borrow().into(), + } + } +} + +impl TryFrom<&[u8]> for KdfEncryptedData { + type Error = Error; + + fn try_from(data: &[u8]) -> Result { + if data.len() < Header::len() { + return Err(Error::InvalidLength); + } + + let header = Header::try_from(&data[0..Header::len()])?; + + let payload = match header.version { + KdfEncryptedDataVersion::V1 => { + KdfEncryptedDataPayload::V1(KdfEncryptedDataV1::try_from(&data[Header::len()..])?) + } + // `Latest` (discriminant 0) is a dispatch sentinel for the encrypt path only; + // it is never written to the wire. Blobs on disk always carry a concrete version. + KdfEncryptedDataVersion::Latest => return Err(Error::UnknownVersion), + }; + + Ok(Self { header, payload }) + } +} + +#[cfg(test)] +mod tests { + use crate::Pbkdf2; +use crate::key_derivation::Argon2; + use crate::utils::validate_header; + + use super::*; + + #[test] + fn derive_encrypt_roundtrip_latest() { + let data = b"derive encrypt payload"; + let password = b"a very strong password"; + let aad = b"public data"; + + let params= Argon2::new().parameters(); + let wrapped = encrypt_with_password_and_aad( + data, + password, + aad, + params, + CiphertextVersion::Latest, + ) + .unwrap(); + + let wrapped_raw: Vec = wrapped.into(); + let wrapped = KdfEncryptedData::try_from(wrapped_raw.as_slice()).unwrap(); + let decrypted = wrapped.decrypt_with_password_and_aad(password, aad).unwrap(); + + assert_eq!(decrypted, data); + } + + #[test] + fn derive_encrypt_pbkdf2() { + let data = b"derive encrypt payload"; + let password = b"a very strong password"; + + let params= Pbkdf2::with_params(10).parameters().unwrap(); + let wrapped = encrypt_with_password( + data, + password, + params, + CiphertextVersion::Latest, + ) + .unwrap(); + + assert!(wrapped.decrypt_with_password(b"wrong password").is_err()); + } + + #[test] + fn derive_encrypt_wrong_password_fails() { + let data = b"derive encrypt payload"; + let password = b"a very strong password"; + + let params= Argon2::new().parameters(); + let wrapped = encrypt_with_password( + data, + password, + params, + CiphertextVersion::Latest, + ) + .unwrap(); + + assert!(wrapped.decrypt_with_password(b"wrong password").is_err()); + } + + #[test] + fn derive_encrypt_wrong_aad_fails() { + let data = b"derive encrypt payload"; + let password = b"a very strong password"; + + let params= Argon2::new().parameters(); + let wrapped = encrypt_with_password_and_aad( + data, + password, + b"the right aad", + params, + CiphertextVersion::Latest, + ) + .unwrap(); + + assert!(wrapped + .decrypt_with_password_and_aad(password, b"wrong aad") + .is_err()); + } + + #[test] + fn validate_header_accepts_derive_encrypt() { + let password = b"a very strong password"; + let params= Argon2::new().parameters(); + let wrapped = encrypt_with_password( + b"derive encrypt payload", + password, + params, + CiphertextVersion::Latest, + ) + .unwrap(); + + let wrapped_raw: Vec = wrapped.into(); + assert!(validate_header(wrapped_raw.as_slice(), DataType::KdfEncryptedData)); + assert!(!validate_header(wrapped_raw.as_slice(), DataType::Ciphertext)); + } +} diff --git a/src/enums.rs b/src/enums.rs index 5f45a9fa..4d457842 100644 --- a/src/enums.rs +++ b/src/enums.rs @@ -33,6 +33,8 @@ pub enum DataType { OnlineCiphertext = 7, /// Serialized key derivation parameters. KeyDerivation = 8, + /// A wrapped payload combining key derivation parameters and ciphertext. + KdfEncryptedData = 9, } /// The versions of the encryption scheme to use. @@ -203,6 +205,20 @@ pub enum KeyDerivationVersion { V2 = 2, } +/// The versions of the KDF-encrypt scheme to use. +#[cfg_attr(feature = "wbindgen", wasm_bindgen())] +#[cfg_attr(feature = "fuzz", derive(Arbitrary))] +#[derive(Clone, Copy, PartialEq, Eq, Zeroize, IntoPrimitive, TryFromPrimitive, Debug)] +#[repr(u16)] +#[derive(Default)] +pub enum KdfEncryptedDataVersion { + /// Uses the latest version. + #[default] + Latest = 0, + /// Uses version 1. + V1 = 1, +} + #[derive(Clone, Copy, PartialEq, Eq, Zeroize, IntoPrimitive, TryFromPrimitive, Debug)] #[cfg_attr(feature = "fuzz", derive(Arbitrary))] #[repr(u16)] @@ -211,3 +227,12 @@ pub enum KeyDerivationSubtype { #[default] None = 0, } + +#[derive(Clone, Copy, PartialEq, Eq, Zeroize, IntoPrimitive, TryFromPrimitive, Debug)] +#[cfg_attr(feature = "fuzz", derive(Arbitrary))] +#[repr(u16)] +#[derive(Default)] +pub enum KdfEncryptedDataSubtype { + #[default] + None = 0, +} diff --git a/src/key_derivation/key_derivation_v2.rs b/src/key_derivation/key_derivation_v2.rs index ae4530d8..b1e1fe8d 100644 --- a/src/key_derivation/key_derivation_v2.rs +++ b/src/key_derivation/key_derivation_v2.rs @@ -3,8 +3,9 @@ use std::convert::TryFrom; use zeroize::Zeroizing; +use crate::derive_encrypt::{encrypt_with_password, KdfEncryptedData}; use crate::key::{secret_key_from_raw, SecretKey}; -use crate::{Argon2Parameters, Error, Header, KeyDerivationVersion, Result}; +use crate::{Argon2Parameters, CiphertextVersion, Error, Header, KeyDerivationVersion, Result}; use super::{DerivationParameters, DerivationParametersPayload}; @@ -95,6 +96,28 @@ impl Argon2 { Ok((secret_key, derivation_params)) } + /// Derives a key from `password` and encrypts `data` in a single step. + /// + /// This is a convenience wrapper that combines [`derive`](Self::derive) with + /// [`encrypt_with_password`](crate::derive_encrypt::encrypt_with_password). + /// + /// # Arguments + /// * `data` - The plaintext data to encrypt. + /// * `password` - The password from which the encryption key is derived. + /// * `version` - Cipher to use. `CiphertextVersion::Latest` is recommended. + /// # Returns + /// Returns the [`KdfEncryptedData`] blob (which can be stored and later decrypted + /// with only the password) and the derived [`SecretKey`] (ready for immediate use). + pub fn derive_and_encrypt( + &self, + data: &[u8], + password: &[u8], + version: CiphertextVersion, + ) -> Result<(KdfEncryptedData, SecretKey)> { + let (secret_key, params) = self.derive(password)?; + let blob = encrypt_with_password(data, password, params, version)?; + Ok((blob, secret_key)) + } } impl Default for Argon2 { @@ -102,3 +125,65 @@ impl Default for Argon2 { Self::new() } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::CiphertextVersion; + + #[test] + fn derive_and_encrypt_roundtrip() { + let data = b"secret payload"; + let password = b"a very strong password"; + + let (blob, _key) = Argon2::new() + .derive_and_encrypt(data, password, CiphertextVersion::Latest) + .unwrap(); + + let blob_bytes: Vec = blob.into(); + let blob = KdfEncryptedData::try_from(blob_bytes.as_slice()).unwrap(); + let plaintext = blob.decrypt_with_password(password).unwrap(); + + assert_eq!(plaintext, data); + } + + #[test] + fn derive_and_encrypt_wrong_password_fails() { + let data = b"secret payload"; + let password = b"a very strong password"; + + let (blob, _key) = Argon2::new() + .derive_and_encrypt(data, password, CiphertextVersion::Latest) + .unwrap(); + + assert!(blob.decrypt_with_password(b"wrong password").is_err()); + } + + #[test] + fn derive_and_encrypt_returns_usable_secret_key() { + let data = b"secret payload"; + let password = b"a very strong password"; + + let (_blob, key1) = Argon2::new() + .derive_and_encrypt(data, password, CiphertextVersion::Latest) + .unwrap(); + + // The returned key must equal the key derived independently from the same password+params. + // Verify by re-deriving from the blob and comparing via encrypt/decrypt symmetry: + // use the key to encrypt separately and confirm it matches. + let key1_bytes: Vec = key1.into(); + assert!(!key1_bytes.is_empty()); + } + + #[test] + fn derive_and_encrypt_empty_data() { + let password = b"password"; + + let (blob, _key) = Argon2::new() + .derive_and_encrypt(b"", password, CiphertextVersion::Latest) + .unwrap(); + + let plaintext = blob.decrypt_with_password(password).unwrap(); + assert_eq!(plaintext, b""); + } +} diff --git a/src/key_derivation/mod.rs b/src/key_derivation/mod.rs index 6bb7301f..4ed34210 100644 --- a/src/key_derivation/mod.rs +++ b/src/key_derivation/mod.rs @@ -41,7 +41,8 @@ use wasm_bindgen::prelude::*; use zeroize::Zeroizing; -use crate::key::SecretKey; +use crate::key::{secret_key_from_raw, SecretKey}; + #[cfg(feature = "fuzz")] use crate::Argon2Parameters; use crate::{DataType, Error, Header, HeaderType, KeyDerivationVersion, Result}; @@ -138,6 +139,16 @@ impl From for Vec { } impl DerivationParameters { + /// Derives a [`SecretKey`] from `password` using these derivation parameters. + pub fn derive(&self, password: &[u8]) -> Result { + let raw = match &self.payload { + DerivationParametersPayload::V1(v1) => v1.derive(password), + DerivationParametersPayload::V2(v2) => v2.derive(password)?, + }; + + secret_key_from_raw(raw) + } + /// Re-derives raw bytes from a password using the stored algorithm and parameters. pub fn compute(&self, password: &[u8]) -> Result>> { match &self.payload { diff --git a/src/lib.rs b/src/lib.rs index 77cf04f2..37ce7ba2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,6 +9,7 @@ //! * [Key Generation](#generation) //! * [Key Exchange](#key-exchange) //! * [Key Derivation](#key-derivation) +//! * [Derive and Encrypt](#derive-and-encrypt) //! * [PasswordHash Module](#passwordhash) //! * [SecretSharing Module](#secretsharing) //! * [Utils Module](#utils) @@ -157,6 +158,36 @@ //! assert_eq!(bob_shared, alice_shared); //! ``` //! +//! ## Derive and Encrypt +//! +//! This module combines password-based key derivation and symmetric encryption into a single +//! self-contained blob. The KDF parameters needed for decryption are stored alongside the +//! ciphertext, so callers only need to supply the original password to decrypt. +//! +//! ```rust +//! use std::convert::TryFrom as _; +//! use devolutions_crypto::derive_encrypt::{encrypt_with_password, KdfEncryptedData}; +//! use devolutions_crypto::key_derivation::Argon2; +//! use devolutions_crypto::CiphertextVersion; +//! +//! let password = b"a very strong password"; +//! let (_, params) = Argon2::new().derive(password).expect("key derivation shouldn't fail"); +//! let blob = encrypt_with_password( +//! b"secret data", +//! password, +//! params, +//! CiphertextVersion::Latest, +//! ).expect("encryption shouldn't fail"); +//! +//! // Serialize to bytes for storage or transport. +//! let blob_bytes: Vec = blob.into(); +//! +//! // Deserialize and decrypt. +//! let blob = KdfEncryptedData::try_from(blob_bytes.as_slice()).expect("deserialization shouldn't fail"); +//! let plaintext = blob.decrypt_with_password(password).expect("decryption shouldn't fail"); +//! assert_eq!(plaintext, b"secret data"); +//! ``` +//! //! ## PasswordHash //! 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. //! ```rust @@ -240,6 +271,7 @@ mod error; mod header; pub mod ciphertext; +pub mod derive_encrypt; pub mod key; pub mod key_derivation; pub mod online_ciphertext; @@ -253,9 +285,9 @@ use enums::{CiphertextSubtype, PasswordHashSubtype, ShareSubtype, SignatureSubty pub use header::{Header, HeaderType}; pub use enums::{ - CiphertextVersion, DataType, KeyDerivationVersion, KeySubtype, KeyVersion, - OnlineCiphertextVersion, PasswordHashVersion, SecretSharingVersion, SignatureVersion, - SigningKeyVersion, + CiphertextVersion, DataType, KdfEncryptedDataVersion, KeyDerivationVersion, KeySubtype, + KeyVersion, OnlineCiphertextVersion, PasswordHashVersion, SecretSharingVersion, + SignatureVersion, SigningKeyVersion, }; pub use argon2::Variant as Argon2Variant; @@ -264,6 +296,7 @@ pub use argon2parameters::defaults as argon2parameters_defaults; pub use argon2parameters::Argon2Parameters; pub use argon2parameters::Argon2ParametersBuilder; pub use error::{Error, Result}; +pub use derive_encrypt::{encrypt_with_password, encrypt_with_password_and_aad, KdfEncryptedData}; pub use key_derivation::{derive_key, Argon2, DerivationParameters, Pbkdf2}; pub const DEFAULT_KEY_SIZE: usize = 32; diff --git a/src/utils.rs b/src/utils.rs index 54ffd380..bff2c39f 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -101,6 +101,7 @@ pub fn derive_key_argon2(key: &[u8], parameters: &Argon2Parameters) -> Result bool { use super::ciphertext::Ciphertext; + use super::derive_encrypt::KdfEncryptedData; use super::key::{PrivateKey, PublicKey}; use super::password_hash::PasswordHash; use super::secret_sharing::Share; @@ -132,6 +133,7 @@ pub fn validate_header(data: &[u8], data_type: DataType) -> bool { use super::key_derivation::DerivationParameters; Header::::try_from(&data[0..Header::len()]).is_ok() } + DataType::KdfEncryptedData => Header::::try_from(&data[0..Header::len()]).is_ok(), } } diff --git a/uniffi/devolutions-crypto-uniffi/src/derive_encrypt.rs b/uniffi/devolutions-crypto-uniffi/src/derive_encrypt.rs new file mode 100644 index 00000000..cf16249a --- /dev/null +++ b/uniffi/devolutions-crypto-uniffi/src/derive_encrypt.rs @@ -0,0 +1,61 @@ +use crate::{CiphertextVersion, KeyDerivationVersion, Result}; +use devolutions_crypto::derive_encrypt::KdfEncryptedData; +use devolutions_crypto::key_derivation::derive_key; + +#[uniffi::export(default(kdf_version = None, ct_version = None))] +pub fn derive_encrypt_with_password( + data: &[u8], + password: &[u8], + kdf_version: Option, + ct_version: Option, +) -> Result> { + let kdf_version = kdf_version.unwrap_or(KeyDerivationVersion::Latest); + let ct_version = ct_version.unwrap_or(CiphertextVersion::Latest); + let (_, params) = derive_key(password, kdf_version)?; + Ok( + devolutions_crypto::derive_encrypt::encrypt_with_password( + data, + password, + params, + ct_version, + )? + .into(), + ) +} + +#[uniffi::export(default(kdf_version = None, ct_version = None))] +pub fn derive_encrypt_with_password_and_aad( + data: &[u8], + password: &[u8], + aad: &[u8], + kdf_version: Option, + ct_version: Option, +) -> Result> { + let kdf_version = kdf_version.unwrap_or(KeyDerivationVersion::Latest); + let ct_version = ct_version.unwrap_or(CiphertextVersion::Latest); + let (_, params) = derive_key(password, kdf_version)?; + Ok( + devolutions_crypto::derive_encrypt::encrypt_with_password_and_aad( + data, + password, + aad, + params, + ct_version, + )? + .into(), + ) +} + +#[uniffi::export] +pub fn derive_decrypt_with_password(data: &[u8], password: &[u8]) -> Result> { + KdfEncryptedData::try_from(data)?.decrypt_with_password(password) +} + +#[uniffi::export] +pub fn derive_decrypt_with_password_and_aad( + data: &[u8], + password: &[u8], + aad: &[u8], +) -> Result> { + KdfEncryptedData::try_from(data)?.decrypt_with_password_and_aad(password, aad) +} diff --git a/uniffi/devolutions-crypto-uniffi/src/lib.rs b/uniffi/devolutions-crypto-uniffi/src/lib.rs index c5c8c42b..08ef4eb5 100644 --- a/uniffi/devolutions-crypto-uniffi/src/lib.rs +++ b/uniffi/devolutions-crypto-uniffi/src/lib.rs @@ -1,5 +1,6 @@ mod argon2parameters; mod ciphertext; +mod derive_encrypt; mod key; mod key_derivation; mod password_hash; @@ -10,6 +11,7 @@ mod utils; pub use argon2parameters::*; pub use ciphertext::*; +pub use derive_encrypt::*; pub use key::*; pub use key_derivation::*; pub use password_hash::*; @@ -19,8 +21,9 @@ pub use signing_key::*; pub use utils::*; pub use devolutions_crypto::{ - CiphertextVersion, DataType, Error as DevolutionsCryptoError, KeyDerivationVersion, KeyVersion, - PasswordHashVersion, Result, SecretSharingVersion, SignatureVersion, SigningKeyVersion, + CiphertextVersion, DataType, KdfEncryptedDataVersion, Error as DevolutionsCryptoError, + KeyDerivationVersion, KeyVersion, PasswordHashVersion, Result, SecretSharingVersion, + SignatureVersion, SigningKeyVersion, }; #[uniffi::remote(Enum)] @@ -34,6 +37,7 @@ pub enum DataType { Signature, OnlineCiphertext, KeyDerivation, + KdfEncryptedData, } #[uniffi::remote(Enum)] @@ -81,6 +85,12 @@ pub enum SigningKeyVersion { V1, } +#[uniffi::remote(Enum)] +pub enum KdfEncryptedDataVersion { + Latest, + V1, +} + #[uniffi::remote(Error)] #[uniffi(flat_error)] pub enum DevolutionsCryptoError { From c9e863bc131adab04f5f6930d0a078531c89206a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Duquette?= Date: Mon, 1 Jun 2026 20:11:01 -0400 Subject: [PATCH 2/7] cargo fmt --- cli/src/main.rs | 17 ++-- ffi/src/lib.rs | 8 +- python/src/lib.rs | 9 ++- src/derive_encrypt/kdf_encrypted_data_v1.rs | 7 +- src/derive_encrypt/mod.rs | 77 +++++++++---------- src/key_derivation/mod.rs | 2 +- src/lib.rs | 2 +- src/utils.rs | 4 +- .../src/derive_encrypt.rs | 19 ++--- uniffi/devolutions-crypto-uniffi/src/lib.rs | 2 +- 10 files changed, 74 insertions(+), 73 deletions(-) diff --git a/cli/src/main.rs b/cli/src/main.rs index 6e9946ad..8acbdd17 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -222,8 +222,14 @@ fn main() { Commands::VerifyPassword { password, hash } => verify_password(hash, password), Commands::MixKeyExchange { private, public } => mix_key_exchange(private, public), Commands::JoinShares { shares } => join_shares(shares), - Commands::DeriveAndEncrypt { data, password, version } => derive_and_encrypt_password(data, password, version), - Commands::DeriveAndDecrypt { data, password } => derive_and_decrypt_password(data, password), + Commands::DeriveAndEncrypt { + data, + password, + version, + } => derive_and_encrypt_password(data, password, version), + Commands::DeriveAndDecrypt { data, password } => { + derive_and_decrypt_password(data, password) + } Commands::PrintHeader { data } => print_header(data), } } @@ -292,9 +298,10 @@ fn derive_and_encrypt_password(data: String, password: String, version: Option = encrypt_with_password(data.as_bytes(), password.as_bytes(), params, version) - .unwrap() - .into(); + let result: Vec = + encrypt_with_password(data.as_bytes(), password.as_bytes(), params, version) + .unwrap() + .into(); println!("{}", base64::encode(&result)); } diff --git a/ffi/src/lib.rs b/ffi/src/lib.rs index 0368b5d8..f00b1b0e 100644 --- a/ffi/src/lib.rs +++ b/ffi/src/lib.rs @@ -25,15 +25,15 @@ use devolutions_crypto::derive_encrypt::{encrypt_with_password_and_aad, KdfEncry use devolutions_crypto::key::{ generate_keypair, generate_secret_key, mix_key_exchange, KeyVersion, PrivateKey, PublicKey, }; -use devolutions_crypto::key_derivation::{Argon2, DerivationParameters, Pbkdf2}; +use devolutions_crypto::key_derivation::{derive_key, 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, }; -use devolutions_crypto::OnlineCiphertextVersion; use devolutions_crypto::KeyDerivationVersion; +use devolutions_crypto::OnlineCiphertextVersion; use devolutions_crypto::{ signature, signature::{Signature, SignatureVersion}, @@ -290,7 +290,9 @@ pub unsafe extern "C" fn DeriveEncryptData( return Error::NullPointer.error_code(); } - if result_length != DeriveEncryptSize(data_length, key_derivation_version, ciphertext_version) as usize { + if result_length + != DeriveEncryptSize(data_length, key_derivation_version, ciphertext_version) as usize + { return Error::InvalidOutputLength.error_code(); } diff --git a/python/src/lib.rs b/python/src/lib.rs index 07342661..d21ada77 100644 --- a/python/src/lib.rs +++ b/python/src/lib.rs @@ -8,6 +8,7 @@ use devolutions_crypto::utils; use devolutions_crypto::Argon2Parameters; use devolutions_crypto::Error; use devolutions_crypto::{ciphertext, ciphertext::Ciphertext}; +use devolutions_crypto::{derive_encrypt, derive_encrypt::KdfEncryptedData}; use devolutions_crypto::{ key, key::{PrivateKey, PublicKey, SecretKey}, @@ -17,8 +18,9 @@ use devolutions_crypto::{ signing_key, signing_key::{SigningKeyPair, SigningPublicKey}, }; -use devolutions_crypto::{CiphertextVersion, KeyDerivationVersion, KeyVersion, SignatureVersion, SigningKeyVersion}; -use devolutions_crypto::{derive_encrypt, derive_encrypt::KdfEncryptedData}; +use devolutions_crypto::{ + CiphertextVersion, KeyDerivationVersion, KeyVersion, SignatureVersion, SigningKeyVersion, +}; enum DevolutionsCryptoError { DevolutionsCrypto(Error), @@ -368,7 +370,8 @@ fn derive_encrypt_with_password( let aad = aad.unwrap_or(&[]); let (_, params) = devolutions_crypto::key_derivation::derive_key(password, kdf_version)?; let result: Vec = - derive_encrypt::encrypt_with_password_and_aad(data, password, aad, params, ct_version)?.into(); + derive_encrypt::encrypt_with_password_and_aad(data, password, aad, params, ct_version)? + .into(); Ok(PyBytes::new(py, &result).into()) } diff --git a/src/derive_encrypt/kdf_encrypted_data_v1.rs b/src/derive_encrypt/kdf_encrypted_data_v1.rs index 2b49c52c..852a9018 100644 --- a/src/derive_encrypt/kdf_encrypted_data_v1.rs +++ b/src/derive_encrypt/kdf_encrypted_data_v1.rs @@ -22,8 +22,7 @@ impl From<&KdfEncryptedDataV1> for Vec { let derivation_parameters: Vec = data.derivation_parameters.clone().into(); let ciphertext: Vec = data.ciphertext.clone().into(); - let mut serialized = - Vec::with_capacity(8 + derivation_parameters.len() + ciphertext.len()); + let mut serialized = Vec::with_capacity(8 + derivation_parameters.len() + ciphertext.len()); serialized .write_u32::(derivation_parameters.len() as u32) @@ -67,7 +66,9 @@ impl TryFrom<&[u8]> for KdfEncryptedDataV1 { cursor.read_exact(&mut ciphertext_raw)?; Ok(Self { - derivation_parameters: DerivationParameters::try_from(derivation_parameters_raw.as_slice())?, + derivation_parameters: DerivationParameters::try_from( + derivation_parameters_raw.as_slice(), + )?, ciphertext: Ciphertext::try_from(ciphertext_raw.as_slice())?, }) } diff --git a/src/derive_encrypt/mod.rs b/src/derive_encrypt/mod.rs index c40425d1..2adbc1bf 100644 --- a/src/derive_encrypt/mod.rs +++ b/src/derive_encrypt/mod.rs @@ -7,7 +7,7 @@ use crate::ciphertext; use crate::enums::KdfEncryptedDataSubtype; use crate::key_derivation::DerivationParameters; use crate::{ - CiphertextVersion, DataType, KdfEncryptedDataVersion, Error, Header, HeaderType, Result, + CiphertextVersion, DataType, Error, Header, HeaderType, KdfEncryptedDataVersion, Result, }; use kdf_encrypted_data_v1::KdfEncryptedDataV1; @@ -61,7 +61,7 @@ enum KdfEncryptedDataPayload { /// use devolutions_crypto::key_derivation::Argon2; /// use devolutions_crypto::CiphertextVersion; /// -/// let (_, params) = Argon2::new().derive(b"password").unwrap(); +/// let params = Argon2::new().parameters(); /// let blob = encrypt_with_password(b"secret", b"password", params, CiphertextVersion::Latest).unwrap(); /// let plaintext = blob.decrypt_with_password(b"password").unwrap(); /// assert_eq!(plaintext, b"secret"); @@ -72,7 +72,13 @@ pub fn encrypt_with_password( derivation_parameters: DerivationParameters, ciphertext_version: CiphertextVersion, ) -> Result { - encrypt_with_password_and_aad(data, password, [].as_slice(), derivation_parameters, ciphertext_version) + encrypt_with_password_and_aad( + data, + password, + [].as_slice(), + derivation_parameters, + ciphertext_version, + ) } /// Encrypts `data` with a key derived from `password` and authenticates `aad`. @@ -97,12 +103,8 @@ pub fn encrypt_with_password_and_aad( header.version = KdfEncryptedDataVersion::V1; let secret_key = derivation_parameters.derive(password)?; - let ciphertext = ciphertext::encrypt_with_secret_key_and_aad( - data, - &secret_key, - aad, - ciphertext_version, - )?; + let ciphertext = + ciphertext::encrypt_with_secret_key_and_aad(data, &secret_key, aad, ciphertext_version)?; Ok(KdfEncryptedData { header, @@ -188,9 +190,9 @@ impl TryFrom<&[u8]> for KdfEncryptedData { #[cfg(test)] mod tests { - use crate::Pbkdf2; -use crate::key_derivation::Argon2; + use crate::key_derivation::Argon2; use crate::utils::validate_header; + use crate::Pbkdf2; use super::*; @@ -200,19 +202,16 @@ use crate::key_derivation::Argon2; let password = b"a very strong password"; let aad = b"public data"; - let params= Argon2::new().parameters(); - let wrapped = encrypt_with_password_and_aad( - data, - password, - aad, - params, - CiphertextVersion::Latest, - ) - .unwrap(); + let params = Argon2::new().parameters(); + let wrapped = + encrypt_with_password_and_aad(data, password, aad, params, CiphertextVersion::Latest) + .unwrap(); let wrapped_raw: Vec = wrapped.into(); let wrapped = KdfEncryptedData::try_from(wrapped_raw.as_slice()).unwrap(); - let decrypted = wrapped.decrypt_with_password_and_aad(password, aad).unwrap(); + let decrypted = wrapped + .decrypt_with_password_and_aad(password, aad) + .unwrap(); assert_eq!(decrypted, data); } @@ -222,14 +221,9 @@ use crate::key_derivation::Argon2; let data = b"derive encrypt payload"; let password = b"a very strong password"; - let params= Pbkdf2::with_params(10).parameters().unwrap(); - let wrapped = encrypt_with_password( - data, - password, - params, - CiphertextVersion::Latest, - ) - .unwrap(); + let params = Pbkdf2::with_params(10).parameters().unwrap(); + let wrapped = + encrypt_with_password(data, password, params, CiphertextVersion::Latest).unwrap(); assert!(wrapped.decrypt_with_password(b"wrong password").is_err()); } @@ -239,14 +233,9 @@ use crate::key_derivation::Argon2; let data = b"derive encrypt payload"; let password = b"a very strong password"; - let params= Argon2::new().parameters(); - let wrapped = encrypt_with_password( - data, - password, - params, - CiphertextVersion::Latest, - ) - .unwrap(); + let params = Argon2::new().parameters(); + let wrapped = + encrypt_with_password(data, password, params, CiphertextVersion::Latest).unwrap(); assert!(wrapped.decrypt_with_password(b"wrong password").is_err()); } @@ -256,7 +245,7 @@ use crate::key_derivation::Argon2; let data = b"derive encrypt payload"; let password = b"a very strong password"; - let params= Argon2::new().parameters(); + let params = Argon2::new().parameters(); let wrapped = encrypt_with_password_and_aad( data, password, @@ -274,7 +263,7 @@ use crate::key_derivation::Argon2; #[test] fn validate_header_accepts_derive_encrypt() { let password = b"a very strong password"; - let params= Argon2::new().parameters(); + let params = Argon2::new().parameters(); let wrapped = encrypt_with_password( b"derive encrypt payload", password, @@ -284,7 +273,13 @@ use crate::key_derivation::Argon2; .unwrap(); let wrapped_raw: Vec = wrapped.into(); - assert!(validate_header(wrapped_raw.as_slice(), DataType::KdfEncryptedData)); - assert!(!validate_header(wrapped_raw.as_slice(), DataType::Ciphertext)); + assert!(validate_header( + wrapped_raw.as_slice(), + DataType::KdfEncryptedData + )); + assert!(!validate_header( + wrapped_raw.as_slice(), + DataType::Ciphertext + )); } } diff --git a/src/key_derivation/mod.rs b/src/key_derivation/mod.rs index 4ed34210..ad34f027 100644 --- a/src/key_derivation/mod.rs +++ b/src/key_derivation/mod.rs @@ -148,7 +148,7 @@ impl DerivationParameters { secret_key_from_raw(raw) } - + /// Re-derives raw bytes from a password using the stored algorithm and parameters. pub fn compute(&self, password: &[u8]) -> Result>> { match &self.payload { diff --git a/src/lib.rs b/src/lib.rs index 37ce7ba2..65c28561 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -295,8 +295,8 @@ pub use argon2::Version as Argon2Version; pub use argon2parameters::defaults as argon2parameters_defaults; pub use argon2parameters::Argon2Parameters; pub use argon2parameters::Argon2ParametersBuilder; -pub use error::{Error, Result}; pub use derive_encrypt::{encrypt_with_password, encrypt_with_password_and_aad, KdfEncryptedData}; +pub use error::{Error, Result}; pub use key_derivation::{derive_key, Argon2, DerivationParameters, Pbkdf2}; pub const DEFAULT_KEY_SIZE: usize = 32; diff --git a/src/utils.rs b/src/utils.rs index bff2c39f..15e3ac9f 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -133,7 +133,9 @@ pub fn validate_header(data: &[u8], data_type: DataType) -> bool { use super::key_derivation::DerivationParameters; Header::::try_from(&data[0..Header::len()]).is_ok() } - DataType::KdfEncryptedData => Header::::try_from(&data[0..Header::len()]).is_ok(), + DataType::KdfEncryptedData => { + Header::::try_from(&data[0..Header::len()]).is_ok() + } } } diff --git a/uniffi/devolutions-crypto-uniffi/src/derive_encrypt.rs b/uniffi/devolutions-crypto-uniffi/src/derive_encrypt.rs index cf16249a..b91d0505 100644 --- a/uniffi/devolutions-crypto-uniffi/src/derive_encrypt.rs +++ b/uniffi/devolutions-crypto-uniffi/src/derive_encrypt.rs @@ -12,15 +12,10 @@ pub fn derive_encrypt_with_password( let kdf_version = kdf_version.unwrap_or(KeyDerivationVersion::Latest); let ct_version = ct_version.unwrap_or(CiphertextVersion::Latest); let (_, params) = derive_key(password, kdf_version)?; - Ok( - devolutions_crypto::derive_encrypt::encrypt_with_password( - data, - password, - params, - ct_version, - )? - .into(), - ) + Ok(devolutions_crypto::derive_encrypt::encrypt_with_password( + data, password, params, ct_version, + )? + .into()) } #[uniffi::export(default(kdf_version = None, ct_version = None))] @@ -36,11 +31,7 @@ pub fn derive_encrypt_with_password_and_aad( let (_, params) = derive_key(password, kdf_version)?; Ok( devolutions_crypto::derive_encrypt::encrypt_with_password_and_aad( - data, - password, - aad, - params, - ct_version, + data, password, aad, params, ct_version, )? .into(), ) diff --git a/uniffi/devolutions-crypto-uniffi/src/lib.rs b/uniffi/devolutions-crypto-uniffi/src/lib.rs index 08ef4eb5..c6b64f3a 100644 --- a/uniffi/devolutions-crypto-uniffi/src/lib.rs +++ b/uniffi/devolutions-crypto-uniffi/src/lib.rs @@ -21,7 +21,7 @@ pub use signing_key::*; pub use utils::*; pub use devolutions_crypto::{ - CiphertextVersion, DataType, KdfEncryptedDataVersion, Error as DevolutionsCryptoError, + CiphertextVersion, DataType, Error as DevolutionsCryptoError, KdfEncryptedDataVersion, KeyDerivationVersion, KeyVersion, PasswordHashVersion, Result, SecretSharingVersion, SignatureVersion, SigningKeyVersion, }; From 0d0d4c89adf407b2bff799b5870704ee5b777f7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Duquette?= Date: Mon, 1 Jun 2026 20:47:18 -0400 Subject: [PATCH 3/7] wasm bindings --- src/wasm.rs | 31 ++++ wrappers/wasm/demo/src/app/app.component.html | 1 + wrappers/wasm/demo/src/app/app.routes.ts | 2 + .../derive-encrypt.component.html | 135 ++++++++++++++++ .../derive-encrypt.component.ts | 127 +++++++++++++++ .../demo/src/app/inspect/inspect.component.ts | 119 ++++++++++++++ .../app/service/encryption.inner.service.ts | 8 + wrappers/wasm/tests/package.json | 2 +- wrappers/wasm/tests/tests/derive-encrypt.ts | 152 ++++++++++++++++++ 9 files changed, 576 insertions(+), 1 deletion(-) create mode 100644 wrappers/wasm/demo/src/app/derive-encrypt/derive-encrypt.component.html create mode 100644 wrappers/wasm/demo/src/app/derive-encrypt/derive-encrypt.component.ts create mode 100644 wrappers/wasm/tests/tests/derive-encrypt.ts diff --git a/src/wasm.rs b/src/wasm.rs index c9f17f69..6048c8e7 100644 --- a/src/wasm.rs +++ b/src/wasm.rs @@ -3,6 +3,7 @@ use std::convert::{TryFrom as _, TryInto as _}; use js_sys::Array; use wasm_bindgen::prelude::*; +use super::derive_encrypt; use super::key_derivation::{Argon2, DerivationParameters, Pbkdf2}; use super::utils; use super::Argon2Parameters; @@ -500,3 +501,33 @@ pub fn base64url_encode(data: &[u8]) -> String { pub fn base64url_decode(data: &str) -> Result, JsValue> { Ok(utils::base64_decode_url(data)?) } + +#[wasm_bindgen(js_name = "deriveEncryptWithPassword")] +pub fn derive_encrypt_with_password( + data: &[u8], + password: &[u8], + aad: Option>, + params: Option, +) -> Result, JsValue> { + let params = params.unwrap_or_else(|| Argon2::new().parameters()); + Ok(derive_encrypt::encrypt_with_password_and_aad( + data, + password, + &aad.unwrap_or_default(), + params, + CiphertextVersion::Latest, + )? + .into()) +} + +#[wasm_bindgen(js_name = "deriveDecryptWithPassword")] +pub fn derive_decrypt_with_password( + data: &[u8], + password: &[u8], + aad: Option>, +) -> Result, JsValue> { + Ok( + derive_encrypt::KdfEncryptedData::try_from(data)? + .decrypt_with_password_and_aad(password, &aad.unwrap_or_default())?, + ) +} diff --git a/wrappers/wasm/demo/src/app/app.component.html b/wrappers/wasm/demo/src/app/app.component.html index 5a3b248e..a06b5ce1 100644 --- a/wrappers/wasm/demo/src/app/app.component.html +++ b/wrappers/wasm/demo/src/app/app.component.html @@ -12,6 +12,7 @@

DevolutionsCrypto

Asymmetric Encryption Password Hashing Key Derivation + Derive and Encrypt Secret Sharing Utilities Encryption (Unmanaged keys) diff --git a/wrappers/wasm/demo/src/app/app.routes.ts b/wrappers/wasm/demo/src/app/app.routes.ts index 1335b0c8..a2210ba3 100644 --- a/wrappers/wasm/demo/src/app/app.routes.ts +++ b/wrappers/wasm/demo/src/app/app.routes.ts @@ -7,6 +7,7 @@ import { AsymmetricComponent } from './asymmetric/asymmetric.component'; import { SecretKeyEncryptionComponent } from './secret-key-encryption/secret-key-encryption.component'; import { InspectComponent } from './inspect/inspect.component'; import { KeyDerivationComponent } from './key-derivation/key-derivation.component'; +import { DeriveEncryptComponent } from './derive-encrypt/derive-encrypt.component'; export const routes: Routes = [ { path: '', redirectTo: '/encryption', pathMatch: 'full' }, @@ -14,6 +15,7 @@ export const routes: Routes = [ { path: 'secret-sharing', component: SecretSharingComponent }, { path: 'password', component: PasswordComponent }, { path: 'key-derivation', component: KeyDerivationComponent }, + { path: 'derive-encrypt', component: DeriveEncryptComponent }, { path: 'utilities', component: UtilitiesComponent }, { path: 'asymmetric', component: AsymmetricComponent }, { path: 'secret-key-encryption', component: SecretKeyEncryptionComponent }, diff --git a/wrappers/wasm/demo/src/app/derive-encrypt/derive-encrypt.component.html b/wrappers/wasm/demo/src/app/derive-encrypt/derive-encrypt.component.html new file mode 100644 index 00000000..0679a68e --- /dev/null +++ b/wrappers/wasm/demo/src/app/derive-encrypt/derive-encrypt.component.html @@ -0,0 +1,135 @@ + +
+ + Devolutions Crypto +
+ +
+ +
+ +

Derive and Encrypt

+
+ +

+ Encrypts data with a key derived directly from a password. The derivation parameters + (including the random salt) are embedded in the output blob — only the password is needed to decrypt. +

+ + +
+
Encrypt
+
+
+ + +
+ +
+ + +
+
+ + +
+ + +
+
+ + +
+
+ + +
+ + + @if (algorithm === 'argon2') { +
+ Argon2 Parameters (optional) +
+ + +
+
+ + +
+
+ + +
+
+

+ A random salt is generated automatically and embedded in the output blob. +

+ } + + + @if (algorithm === 'pbkdf2') { +
+ PBKDF2 Parameters (optional) +
+ + +
+
+ + +
+
+ } + +
+ +
+
+ Result (base64) + +
+ +
+
+
+ + +
+
Decrypt
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ Result + +
+
+
+
+ +
diff --git a/wrappers/wasm/demo/src/app/derive-encrypt/derive-encrypt.component.ts b/wrappers/wasm/demo/src/app/derive-encrypt/derive-encrypt.component.ts new file mode 100644 index 00000000..81b07760 --- /dev/null +++ b/wrappers/wasm/demo/src/app/derive-encrypt/derive-encrypt.component.ts @@ -0,0 +1,127 @@ +import { Component, OnInit } from '@angular/core'; +import { FormGroup, FormControl, ReactiveFormsModule } from '@angular/forms'; +import { EncryptionService } from '../service/encryption.service'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { faShieldHalved } from '@fortawesome/free-solid-svg-icons'; +import * as functions from '../shared/shared.component'; + +type EncryptionServiceInner = typeof import('../service/encryption.inner.service'); + +@Component({ + selector: 'app-derive-encrypt', + standalone: true, + imports: [ReactiveFormsModule, FaIconComponent], + templateUrl: './derive-encrypt.component.html', +}) +export class DeriveEncryptComponent implements OnInit { + faShieldHalved = faShieldHalved; + + algorithm: 'argon2' | 'pbkdf2' = 'argon2'; + + encryptForm: FormGroup; + decryptForm: FormGroup; + + encoder: TextEncoder; + decoder: TextDecoder; + + constructor(private encryptionService: EncryptionService) { + this.encoder = new TextEncoder(); + this.decoder = new TextDecoder(); + + this.encryptForm = new FormGroup({ + plaintext: new FormControl(''), + password: new FormControl(''), + aad: new FormControl(''), + // Argon2 params + argon2Memory: new FormControl(''), + argon2Iterations: new FormControl(''), + argon2Lanes: new FormControl(''), + // PBKDF2 params + pbkdf2Iterations: new FormControl(''), + pbkdf2Salt: new FormControl(''), + // Output + encryptResult: new FormControl(''), + }); + + this.decryptForm = new FormGroup({ + ciphertext: new FormControl(''), + password: new FormControl(''), + aad: new FormControl(''), + decryptResult: new FormControl(''), + }); + } + + ngOnInit() {} + + setAlgorithm(algo: 'argon2' | 'pbkdf2') { + this.algorithm = algo; + this.encryptForm.patchValue({ encryptResult: '' }); + } + + w3Open() { functions.w3_open(); } + w3Close() { functions.w3_close(); } + + async encrypt() { + const service: EncryptionServiceInner = await this.encryptionService.innerModule; + + const plaintext: string = this.encryptForm.value.plaintext; + const password: string = this.encryptForm.value.password; + if (!plaintext || !password) { return; } + + const plaintextBytes = this.encoder.encode(plaintext); + const passwordBytes = this.encoder.encode(password); + const aadStr: string = this.encryptForm.value.aad; + const aad: Uint8Array | undefined = aadStr ? this.encoder.encode(aadStr) : undefined; + + try { + let params: import('@devolutions/devolutions-crypto-web').DerivationParameters | undefined; + + if (this.algorithm === 'argon2') { + const argon2Params = new service.Argon2Parameters(); + const memory: string = this.encryptForm.value.argon2Memory; + const iterations: string = this.encryptForm.value.argon2Iterations; + const lanes: string = this.encryptForm.value.argon2Lanes; + if (memory) { argon2Params.memory = Number(memory); } + if (iterations) { argon2Params.iterations = Number(iterations); } + if (lanes) { argon2Params.lanes = Number(lanes); } + params = service.deriveSecretKeyArgon2(passwordBytes, argon2Params).parameters; + } else { + const iterStr: string = this.encryptForm.value.pbkdf2Iterations; + const saltStr: string = this.encryptForm.value.pbkdf2Salt; + const iterations: number | undefined = iterStr ? Number(iterStr) : undefined; + let kdfResult; + if (saltStr) { + kdfResult = service.deriveSecretKeyPbkdf2WithSalt(passwordBytes, this.encoder.encode(saltStr), iterations); + } else { + kdfResult = service.deriveSecretKeyPbkdf2(passwordBytes, iterations); + } + params = kdfResult.parameters; + } + + const blob = service.deriveEncryptWithPassword(plaintextBytes, passwordBytes, aad, params); + this.encryptForm.patchValue({ encryptResult: service.base64encode(blob) }); + } catch (e: any) { + this.encryptForm.patchValue({ encryptResult: `Error: ${e?.message ?? e}` }); + } + } + + async decrypt() { + const service: EncryptionServiceInner = await this.encryptionService.innerModule; + + const ciphertextStr: string = this.decryptForm.value.ciphertext; + const password: string = this.decryptForm.value.password; + if (!ciphertextStr || !password) { return; } + + const passwordBytes = this.encoder.encode(password); + const aadStr: string = this.decryptForm.value.aad; + const aad: Uint8Array | undefined = aadStr ? this.encoder.encode(aadStr) : undefined; + + try { + const blob = service.base64decode(ciphertextStr.trim()); + const plaintext = service.deriveDecryptWithPassword(blob, passwordBytes, aad); + this.decryptForm.patchValue({ decryptResult: this.decoder.decode(plaintext) }); + } catch (e: any) { + this.decryptForm.patchValue({ decryptResult: `Error: ${e?.message ?? e}` }); + } + } +} diff --git a/wrappers/wasm/demo/src/app/inspect/inspect.component.ts b/wrappers/wasm/demo/src/app/inspect/inspect.component.ts index 104b11dc..11ec5729 100644 --- a/wrappers/wasm/demo/src/app/inspect/inspect.component.ts +++ b/wrappers/wasm/demo/src/app/inspect/inspect.component.ts @@ -21,6 +21,7 @@ const DATA_TYPE_NAMES: Record = { 6: 'Signature', 7: 'OnlineCiphertext', 8: 'KeyDerivation', + 9: 'KdfEncryptedData', }; const SUBTYPE_NAMES: Record> = { @@ -31,6 +32,7 @@ const SUBTYPE_NAMES: Record> = { 5: { 0: 'None', 1: 'Pair', 2: 'Public' }, 6: { 0: 'None' }, 8: { 0: 'None' }, + 9: { 0: 'None' }, }; const VERSION_NAMES: Record> = { @@ -41,6 +43,7 @@ const VERSION_NAMES: Record> = { 5: { 0: 'Latest', 1: 'V1 – Ed25519' }, 6: { 0: 'Latest', 1: 'V1 – Ed25519' }, 8: { 0: 'Latest', 1: 'V1 – PBKDF2-HMAC-SHA256', 2: 'V2 – Argon2id' }, + 9: { 0: 'Latest', 1: 'V1 – DerivationParameters + XChaCha20-Poly1305' }, }; export interface PayloadField { @@ -196,6 +199,8 @@ export class InspectComponent implements OnInit { return this.parseSharePayload(payload, abs); case 8: return this.parseDerivationParametersPayload(payload, version, abs); + case 9: + return this.parseKdfEncryptedDataPayload(payload, abs); default: return [ { @@ -713,6 +718,120 @@ export class InspectComponent implements OnInit { ]; } + private parseKdfEncryptedDataPayload( + payload: Uint8Array, + abs: (n: number) => number + ): PayloadField[] { + const fields: PayloadField[] = []; + if (payload.length < 8) { + fields.push({ + name: 'Error', + offset: abs(0), + size: payload.length, + hex: toHex(payload), + description: `Payload too short for KdfEncryptedData V1 (min 8 bytes, got ${payload.length})`, + }); + return fields; + } + + const dv = new DataView(payload.buffer, payload.byteOffset, payload.byteLength); + const paramsLen = dv.getUint32(0, true); + const ctLen = dv.getUint32(4, true); + + fields.push({ + name: 'DerivationParameters Length', + offset: abs(0), + size: 4, + hex: toHex(payload.slice(0, 4)), + description: `Serialized DerivationParameters size: ${paramsLen} bytes`, + }); + fields.push({ + name: 'Ciphertext Length', + offset: abs(4), + size: 4, + hex: toHex(payload.slice(4, 8)), + description: `Serialized Ciphertext size: ${ctLen} bytes`, + }); + + let pos = 8; + + // DerivationParameters (has its own 8-byte header) + if (pos + paramsLen > payload.length) { + fields.push({ + name: 'Error', + offset: abs(pos), + size: payload.length - pos, + hex: toHex(payload.slice(pos)), + description: `Truncated: DerivationParameters claims ${paramsLen} bytes but only ${payload.length - pos} remain`, + }); + return fields; + } + + const dpBytes = payload.slice(pos, pos + paramsLen); + if (dpBytes.length >= 8) { + const dpDv = new DataView(dpBytes.buffer, dpBytes.byteOffset, dpBytes.byteLength); + const dpVersion = dpDv.getUint16(6, true); + fields.push({ + name: 'DerivationParameters Header', + offset: abs(pos), + size: 8, + hex: toHex(dpBytes.slice(0, 8)), + description: `Inner header — KeyDerivation, version ${dpVersion}`, + }); + const dpPayload = dpBytes.slice(8); + const dpFields = this.parseDerivationParametersPayload(dpPayload, dpVersion, (n) => abs(pos + 8 + n)); + fields.push(...dpFields); + } else { + fields.push({ + name: 'DerivationParameters', + offset: abs(pos), + size: paramsLen, + hex: toHex(dpBytes), + description: `Serialized DerivationParameters (${paramsLen} bytes)`, + }); + } + pos += paramsLen; + + // Ciphertext (has its own 8-byte header) + if (pos + ctLen > payload.length) { + fields.push({ + name: 'Error', + offset: abs(pos), + size: payload.length - pos, + hex: toHex(payload.slice(pos)), + description: `Truncated: Ciphertext claims ${ctLen} bytes but only ${payload.length - pos} remain`, + }); + return fields; + } + + const ctBytes = payload.slice(pos, pos + ctLen); + if (ctBytes.length >= 8) { + const ctDv = new DataView(ctBytes.buffer, ctBytes.byteOffset, ctBytes.byteLength); + const ctSubtype = ctDv.getUint16(4, true); + const ctVersion = ctDv.getUint16(6, true); + fields.push({ + name: 'Ciphertext Header', + offset: abs(pos), + size: 8, + hex: toHex(ctBytes.slice(0, 8)), + description: `Inner header — Ciphertext, subtype ${ctSubtype}, version ${ctVersion}`, + }); + const ctPayload = ctBytes.slice(8); + const ctFields = this.parseCiphertextPayload(ctPayload, ctSubtype, ctVersion, (n) => abs(pos + 8 + n)); + fields.push(...ctFields); + } else { + fields.push({ + name: 'Ciphertext', + offset: abs(pos), + size: ctLen, + hex: toHex(ctBytes), + description: `Serialized Ciphertext (${ctLen} bytes)`, + }); + } + + return fields; + } + private errorResult(totalBytes: number, message: string): ParseResult { return { signatureHex: '', 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 085a33e4..bc0fe0be 100644 --- a/wrappers/wasm/demo/src/app/service/encryption.inner.service.ts +++ b/wrappers/wasm/demo/src/app/service/encryption.inner.service.ts @@ -97,3 +97,11 @@ export function deriveSecretKeyPbkdf2WithSalt(password: Uint8Array, salt: Uint8A export function deriveSecretKeyArgon2(password: Uint8Array, parameters: Argon2Parameters): KeyDerivationResult { return devolutionsCrypto.deriveSecretKeyArgon2(password, parameters); } + +export function deriveEncryptWithPassword(data: Uint8Array, password: Uint8Array, aad?: Uint8Array, params?: DerivationParameters): Uint8Array { + return devolutionsCrypto.deriveEncryptWithPassword(data, password, aad, params); +} + +export function deriveDecryptWithPassword(data: Uint8Array, password: Uint8Array, aad?: Uint8Array): Uint8Array { + return devolutionsCrypto.deriveDecryptWithPassword(data, password, aad); +} diff --git a/wrappers/wasm/tests/package.json b/wrappers/wasm/tests/package.json index 73f2471b..0a3e151b 100644 --- a/wrappers/wasm/tests/package.json +++ b/wrappers/wasm/tests/package.json @@ -4,7 +4,7 @@ "description": "Tests for the devolutions cryptographic library", "type": "module", "scripts": { - "test": "tsx --test tests/asymmetric.ts tests/conformity.ts tests/hashing.ts tests/key-derivation.ts tests/secret-sharing.ts tests/signature.ts tests/symmetric.ts tests/utils.ts", + "test": "tsx --test tests/asymmetric.ts tests/conformity.ts tests/derive-encrypt.ts tests/hashing.ts tests/key-derivation.ts tests/secret-sharing.ts tests/signature.ts tests/symmetric.ts tests/utils.ts", "test:watch": "tsx --test --watch tests/*.ts" }, "repository": { diff --git a/wrappers/wasm/tests/tests/derive-encrypt.ts b/wrappers/wasm/tests/tests/derive-encrypt.ts new file mode 100644 index 00000000..13934e64 --- /dev/null +++ b/wrappers/wasm/tests/tests/derive-encrypt.ts @@ -0,0 +1,152 @@ +import { + deriveEncryptWithPassword, + deriveDecryptWithPassword, + deriveSecretKeyPbkdf2WithSalt, + deriveSecretKeyArgon2, + Argon2Parameters, + DerivationParameters, + base64encode, + base64decode, +} from 'devolutions-crypto' +import { describe, test } from 'node:test' +import assert from 'node:assert/strict' + +const encoder: TextEncoder = new TextEncoder() + +describe('derive encrypt/decrypt', () => { + test('roundtrip with password', () => { + const plaintext = encoder.encode('hello world') + const password = encoder.encode('mypassword') + + const blob = deriveEncryptWithPassword(plaintext, password) + const decrypted = deriveDecryptWithPassword(blob, password) + + assert.deepStrictEqual(decrypted, plaintext) + }) + + test('encrypted blob differs from plaintext', () => { + const plaintext = encoder.encode('sensitive data') + const password = encoder.encode('password123') + + const blob = deriveEncryptWithPassword(plaintext, password) + + assert.notDeepStrictEqual(blob, plaintext) + }) + + test('each encryption produces a different blob (random salt)', () => { + const plaintext = encoder.encode('same data') + const password = encoder.encode('same password') + + const blob1 = deriveEncryptWithPassword(plaintext, password) + const blob2 = deriveEncryptWithPassword(plaintext, password) + + assert.notStrictEqual(base64encode(blob1), base64encode(blob2)) + }) + + test('wrong password fails to decrypt', () => { + const plaintext = encoder.encode('secret') + const password = encoder.encode('correct-password') + + const blob = deriveEncryptWithPassword(plaintext, password) + + assert.throws(() => { + deriveDecryptWithPassword(blob, encoder.encode('wrong-password')) + }) + }) + + test('roundtrip with aad', () => { + const plaintext = encoder.encode('authenticated data') + const password = encoder.encode('mypassword') + const aad = encoder.encode('context') + + const blob = deriveEncryptWithPassword(plaintext, password, aad) + const decrypted = deriveDecryptWithPassword(blob, password, aad) + + assert.deepStrictEqual(decrypted, plaintext) + }) + + test('wrong aad fails to decrypt', () => { + const plaintext = encoder.encode('authenticated data') + const password = encoder.encode('mypassword') + const aad = encoder.encode('context') + + const blob = deriveEncryptWithPassword(plaintext, password, aad) + + assert.throws(() => { + deriveDecryptWithPassword(blob, password, encoder.encode('wrong-context')) + }) + }) + + test('aad-encrypted blob cannot be decrypted without aad', () => { + const plaintext = encoder.encode('authenticated data') + const password = encoder.encode('mypassword') + const aad = encoder.encode('context') + + const blob = deriveEncryptWithPassword(plaintext, password, aad) + + assert.throws(() => { + deriveDecryptWithPassword(blob, password) + }) + }) + + test('roundtrip with explicit Argon2 DerivationParameters', () => { + const plaintext = encoder.encode('hello world') + const password = encoder.encode('mypassword') + const { parameters } = deriveSecretKeyArgon2(password, new Argon2Parameters()) + + const blob = deriveEncryptWithPassword(plaintext, password, undefined, parameters) + const decrypted = deriveDecryptWithPassword(blob, password) + + assert.deepStrictEqual(decrypted, plaintext) + }) + + test('fixed Argon2 parameters produce different blobs (ciphertext nonce is random)', () => { + const plaintext = encoder.encode('same data') + const password = encoder.encode('same password') + const { parameters } = deriveSecretKeyArgon2(password, new Argon2Parameters()) + + const blob1 = deriveEncryptWithPassword(plaintext, password, undefined, parameters) + const blob2 = deriveEncryptWithPassword(plaintext, password, undefined, parameters) + + assert.notStrictEqual(base64encode(blob1), base64encode(blob2)) + }) + + test('roundtrip with explicit PBKDF2 DerivationParameters', () => { + const plaintext = encoder.encode('hello world') + const password = encoder.encode('mypassword') + const salt = encoder.encode('fixed_salt_16byt') + const { parameters } = deriveSecretKeyPbkdf2WithSalt(password, salt, 10) + + const blob = deriveEncryptWithPassword(plaintext, password, undefined, parameters) + const decrypted = deriveDecryptWithPassword(blob, password) + + assert.deepStrictEqual(decrypted, plaintext) + }) + + test('roundtrip with explicit parameters and aad', () => { + const plaintext = encoder.encode('secure payload') + const password = encoder.encode('mypassword') + const aad = encoder.encode('context') + const { parameters } = deriveSecretKeyArgon2(password, new Argon2Parameters()) + + const blob = deriveEncryptWithPassword(plaintext, password, aad, parameters) + const decrypted = deriveDecryptWithPassword(blob, password, aad) + + assert.deepStrictEqual(decrypted, plaintext) + }) + + test('DerivationParameters round-trip through bytes when used with derive-encrypt', () => { + const password = encoder.encode('mypassword') + const { parameters } = deriveSecretKeyArgon2(password, new Argon2Parameters()) + + const paramsBytes = parameters.bytes + const restored: DerivationParameters = DerivationParameters.fromBytes(paramsBytes) + + const plaintext = encoder.encode('hello') + const blob = deriveEncryptWithPassword(plaintext, password, undefined, restored) + const decrypted = deriveDecryptWithPassword(blob, password) + + assert.deepStrictEqual(decrypted, plaintext) + }) +}) + From 834d0ba269007e3440319ac6b25b1caa4377788d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Duquette?= Date: Tue, 2 Jun 2026 13:12:10 -0400 Subject: [PATCH 4/7] Update demo application --- src/wasm.rs | 3 +- .../derive-encrypt.component.html | 31 +++++++++++++++---- .../derive-encrypt.component.ts | 9 +++++- .../demo/src/app/inspect/inspect.component.ts | 2 +- .../app/service/encryption.inner.service.ts | 4 +-- 5 files changed, 38 insertions(+), 11 deletions(-) diff --git a/src/wasm.rs b/src/wasm.rs index 6048c8e7..29b433f1 100644 --- a/src/wasm.rs +++ b/src/wasm.rs @@ -508,6 +508,7 @@ pub fn derive_encrypt_with_password( password: &[u8], aad: Option>, params: Option, + version: Option, ) -> Result, JsValue> { let params = params.unwrap_or_else(|| Argon2::new().parameters()); Ok(derive_encrypt::encrypt_with_password_and_aad( @@ -515,7 +516,7 @@ pub fn derive_encrypt_with_password( password, &aad.unwrap_or_default(), params, - CiphertextVersion::Latest, + version.unwrap_or(CiphertextVersion::Latest), )? .into()) } diff --git a/wrappers/wasm/demo/src/app/derive-encrypt/derive-encrypt.component.html b/wrappers/wasm/demo/src/app/derive-encrypt/derive-encrypt.component.html index 0679a68e..ee77fb1c 100644 --- a/wrappers/wasm/demo/src/app/derive-encrypt/derive-encrypt.component.html +++ b/wrappers/wasm/demo/src/app/derive-encrypt/derive-encrypt.component.html @@ -41,6 +41,25 @@

Derive and Encrypt

+ +
+ +
+ + +
+
+
@@ -57,8 +76,8 @@

Derive and Encrypt

@if (algorithm === 'argon2') { -
- Argon2 Parameters (optional) +
+ Argon2 Parameters (optional)
@@ -71,7 +90,7 @@

Derive and Encrypt

-
+

A random salt is generated automatically and embedded in the output blob.

@@ -79,8 +98,8 @@

Derive and Encrypt

@if (algorithm === 'pbkdf2') { -
- PBKDF2 Parameters (optional) +
+ PBKDF2 Parameters (optional)
@@ -89,7 +108,7 @@

Derive and Encrypt

-
+ }
diff --git a/wrappers/wasm/demo/src/app/derive-encrypt/derive-encrypt.component.ts b/wrappers/wasm/demo/src/app/derive-encrypt/derive-encrypt.component.ts index 81b07760..702fc309 100644 --- a/wrappers/wasm/demo/src/app/derive-encrypt/derive-encrypt.component.ts +++ b/wrappers/wasm/demo/src/app/derive-encrypt/derive-encrypt.component.ts @@ -17,6 +17,7 @@ export class DeriveEncryptComponent implements OnInit { faShieldHalved = faShieldHalved; algorithm: 'argon2' | 'pbkdf2' = 'argon2'; + cipherAlgorithm: 'xchacha20' | 'aes' = 'xchacha20'; encryptForm: FormGroup; decryptForm: FormGroup; @@ -58,6 +59,11 @@ export class DeriveEncryptComponent implements OnInit { this.encryptForm.patchValue({ encryptResult: '' }); } + setCipherAlgorithm(cipher: 'xchacha20' | 'aes') { + this.cipherAlgorithm = cipher; + this.encryptForm.patchValue({ encryptResult: '' }); + } + w3Open() { functions.w3_open(); } w3Close() { functions.w3_close(); } @@ -98,7 +104,8 @@ export class DeriveEncryptComponent implements OnInit { params = kdfResult.parameters; } - const blob = service.deriveEncryptWithPassword(plaintextBytes, passwordBytes, aad, params); + const version = this.cipherAlgorithm === 'aes' ? service.CiphertextVersion.V1 : service.CiphertextVersion.V2; + const blob = service.deriveEncryptWithPassword(plaintextBytes, passwordBytes, aad, params, version); this.encryptForm.patchValue({ encryptResult: service.base64encode(blob) }); } catch (e: any) { this.encryptForm.patchValue({ encryptResult: `Error: ${e?.message ?? e}` }); diff --git a/wrappers/wasm/demo/src/app/inspect/inspect.component.ts b/wrappers/wasm/demo/src/app/inspect/inspect.component.ts index 11ec5729..60eb5e24 100644 --- a/wrappers/wasm/demo/src/app/inspect/inspect.component.ts +++ b/wrappers/wasm/demo/src/app/inspect/inspect.component.ts @@ -43,7 +43,7 @@ const VERSION_NAMES: Record> = { 5: { 0: 'Latest', 1: 'V1 – Ed25519' }, 6: { 0: 'Latest', 1: 'V1 – Ed25519' }, 8: { 0: 'Latest', 1: 'V1 – PBKDF2-HMAC-SHA256', 2: 'V2 – Argon2id' }, - 9: { 0: 'Latest', 1: 'V1 – DerivationParameters + XChaCha20-Poly1305' }, + 9: { 0: 'Latest', 1: 'V1 – DerivationParameters + Ciphertext' }, }; export interface PayloadField { 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 bc0fe0be..198107ee 100644 --- a/wrappers/wasm/demo/src/app/service/encryption.inner.service.ts +++ b/wrappers/wasm/demo/src/app/service/encryption.inner.service.ts @@ -98,8 +98,8 @@ export function deriveSecretKeyArgon2(password: Uint8Array, parameters: Argon2Pa return devolutionsCrypto.deriveSecretKeyArgon2(password, parameters); } -export function deriveEncryptWithPassword(data: Uint8Array, password: Uint8Array, aad?: Uint8Array, params?: DerivationParameters): Uint8Array { - return devolutionsCrypto.deriveEncryptWithPassword(data, password, aad, params); +export function deriveEncryptWithPassword(data: Uint8Array, password: Uint8Array, aad?: Uint8Array, params?: DerivationParameters, version?: CiphertextVersion): Uint8Array { + return devolutionsCrypto.deriveEncryptWithPassword(data, password, aad, params, version); } export function deriveDecryptWithPassword(data: Uint8Array, password: Uint8Array, aad?: Uint8Array): Uint8Array { From 312aab91b8ec948eca9858c76cd72d7ca41b2631 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Duquette?= Date: Tue, 2 Jun 2026 13:38:23 -0400 Subject: [PATCH 5/7] copilot review --- python/src/lib.rs | 10 ++- src/derive_encrypt/kdf_encrypted_data_v1.rs | 8 +- src/derive_encrypt/mod.rs | 5 +- src/key_derivation/key_derivation_v2.rs | 87 +------------------ src/lib.rs | 2 +- src/wasm.rs | 6 +- .../src/derive_encrypt.rs | 17 +++- 7 files changed, 32 insertions(+), 103 deletions(-) diff --git a/python/src/lib.rs b/python/src/lib.rs index d21ada77..9cc26759 100644 --- a/python/src/lib.rs +++ b/python/src/lib.rs @@ -5,7 +5,6 @@ use pyo3::types::{PyBool, PyBytes}; use std::convert::TryFrom; use devolutions_crypto::utils; -use devolutions_crypto::Argon2Parameters; use devolutions_crypto::Error; use devolutions_crypto::{ciphertext, ciphertext::Ciphertext}; use devolutions_crypto::{derive_encrypt, derive_encrypt::KdfEncryptedData}; @@ -18,6 +17,7 @@ use devolutions_crypto::{ signing_key, signing_key::{SigningKeyPair, SigningPublicKey}, }; +use devolutions_crypto::{Argon2, Argon2Parameters, Pbkdf2}; use devolutions_crypto::{ CiphertextVersion, KeyDerivationVersion, KeyVersion, SignatureVersion, SigningKeyVersion, }; @@ -368,7 +368,13 @@ fn derive_encrypt_with_password( Err(_) => return Err(Error::UnknownVersion.into()), }; let aad = aad.unwrap_or(&[]); - let (_, params) = devolutions_crypto::key_derivation::derive_key(password, kdf_version)?; + + let params = match kdf_version { + KeyDerivationVersion::Latest | KeyDerivationVersion::V2 => Argon2::new().parameters(), + KeyDerivationVersion::V1 => Pbkdf2::new() + .parameters() + .expect("default PKBDF2 parameters shouldn't fail"), + }; let result: Vec = derive_encrypt::encrypt_with_password_and_aad(data, password, aad, params, ct_version)? .into(); diff --git a/src/derive_encrypt/kdf_encrypted_data_v1.rs b/src/derive_encrypt/kdf_encrypted_data_v1.rs index 852a9018..f9c48d6e 100644 --- a/src/derive_encrypt/kdf_encrypted_data_v1.rs +++ b/src/derive_encrypt/kdf_encrypted_data_v1.rs @@ -17,10 +17,10 @@ pub struct KdfEncryptedDataV1 { pub ciphertext: Ciphertext, } -impl From<&KdfEncryptedDataV1> for Vec { - fn from(data: &KdfEncryptedDataV1) -> Self { - let derivation_parameters: Vec = data.derivation_parameters.clone().into(); - let ciphertext: Vec = data.ciphertext.clone().into(); +impl From for Vec { + fn from(data: KdfEncryptedDataV1) -> Self { + let derivation_parameters: Vec = data.derivation_parameters.into(); + let ciphertext: Vec = data.ciphertext.into(); let mut serialized = Vec::with_capacity(8 + derivation_parameters.len() + ciphertext.len()); diff --git a/src/derive_encrypt/mod.rs b/src/derive_encrypt/mod.rs index 2adbc1bf..376acac2 100644 --- a/src/derive_encrypt/mod.rs +++ b/src/derive_encrypt/mod.rs @@ -50,8 +50,7 @@ enum KdfEncryptedDataPayload { /// # Arguments /// * `data` - The plaintext data to encrypt. /// * `password` - The password from which the encryption key is derived. -/// * `derivation_parameters` - Pre-built key derivation parameters (includes the salt). Use -/// [`Argon2::new().derive(password)`](crate::key_derivation::Argon2::derive) to generate them. +/// * `derivation_parameters` - Pre-built key derivation parameters. /// * `ciphertext_version` - Cipher to use. `CiphertextVersion::Latest` is recommended. /// # Returns /// Returns a [`KdfEncryptedData`] blob containing the key derivation parameters and the ciphertext. @@ -160,7 +159,7 @@ impl From for Vec { impl From for Vec { fn from(data: KdfEncryptedDataPayload) -> Self { match data { - KdfEncryptedDataPayload::V1(v1) => v1.borrow().into(), + KdfEncryptedDataPayload::V1(v1) => v1.into(), } } } diff --git a/src/key_derivation/key_derivation_v2.rs b/src/key_derivation/key_derivation_v2.rs index b1e1fe8d..ae4530d8 100644 --- a/src/key_derivation/key_derivation_v2.rs +++ b/src/key_derivation/key_derivation_v2.rs @@ -3,9 +3,8 @@ use std::convert::TryFrom; use zeroize::Zeroizing; -use crate::derive_encrypt::{encrypt_with_password, KdfEncryptedData}; use crate::key::{secret_key_from_raw, SecretKey}; -use crate::{Argon2Parameters, CiphertextVersion, Error, Header, KeyDerivationVersion, Result}; +use crate::{Argon2Parameters, Error, Header, KeyDerivationVersion, Result}; use super::{DerivationParameters, DerivationParametersPayload}; @@ -96,28 +95,6 @@ impl Argon2 { Ok((secret_key, derivation_params)) } - /// Derives a key from `password` and encrypts `data` in a single step. - /// - /// This is a convenience wrapper that combines [`derive`](Self::derive) with - /// [`encrypt_with_password`](crate::derive_encrypt::encrypt_with_password). - /// - /// # Arguments - /// * `data` - The plaintext data to encrypt. - /// * `password` - The password from which the encryption key is derived. - /// * `version` - Cipher to use. `CiphertextVersion::Latest` is recommended. - /// # Returns - /// Returns the [`KdfEncryptedData`] blob (which can be stored and later decrypted - /// with only the password) and the derived [`SecretKey`] (ready for immediate use). - pub fn derive_and_encrypt( - &self, - data: &[u8], - password: &[u8], - version: CiphertextVersion, - ) -> Result<(KdfEncryptedData, SecretKey)> { - let (secret_key, params) = self.derive(password)?; - let blob = encrypt_with_password(data, password, params, version)?; - Ok((blob, secret_key)) - } } impl Default for Argon2 { @@ -125,65 +102,3 @@ impl Default for Argon2 { Self::new() } } - -#[cfg(test)] -mod tests { - use super::*; - use crate::CiphertextVersion; - - #[test] - fn derive_and_encrypt_roundtrip() { - let data = b"secret payload"; - let password = b"a very strong password"; - - let (blob, _key) = Argon2::new() - .derive_and_encrypt(data, password, CiphertextVersion::Latest) - .unwrap(); - - let blob_bytes: Vec = blob.into(); - let blob = KdfEncryptedData::try_from(blob_bytes.as_slice()).unwrap(); - let plaintext = blob.decrypt_with_password(password).unwrap(); - - assert_eq!(plaintext, data); - } - - #[test] - fn derive_and_encrypt_wrong_password_fails() { - let data = b"secret payload"; - let password = b"a very strong password"; - - let (blob, _key) = Argon2::new() - .derive_and_encrypt(data, password, CiphertextVersion::Latest) - .unwrap(); - - assert!(blob.decrypt_with_password(b"wrong password").is_err()); - } - - #[test] - fn derive_and_encrypt_returns_usable_secret_key() { - let data = b"secret payload"; - let password = b"a very strong password"; - - let (_blob, key1) = Argon2::new() - .derive_and_encrypt(data, password, CiphertextVersion::Latest) - .unwrap(); - - // The returned key must equal the key derived independently from the same password+params. - // Verify by re-deriving from the blob and comparing via encrypt/decrypt symmetry: - // use the key to encrypt separately and confirm it matches. - let key1_bytes: Vec = key1.into(); - assert!(!key1_bytes.is_empty()); - } - - #[test] - fn derive_and_encrypt_empty_data() { - let password = b"password"; - - let (blob, _key) = Argon2::new() - .derive_and_encrypt(b"", password, CiphertextVersion::Latest) - .unwrap(); - - let plaintext = blob.decrypt_with_password(password).unwrap(); - assert_eq!(plaintext, b""); - } -} diff --git a/src/lib.rs b/src/lib.rs index 65c28561..5239d074 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -171,7 +171,7 @@ //! use devolutions_crypto::CiphertextVersion; //! //! let password = b"a very strong password"; -//! let (_, params) = Argon2::new().derive(password).expect("key derivation shouldn't fail"); +//! let params = Argon2::new().parameters(); //! let blob = encrypt_with_password( //! b"secret data", //! password, diff --git a/src/wasm.rs b/src/wasm.rs index 29b433f1..22a70e15 100644 --- a/src/wasm.rs +++ b/src/wasm.rs @@ -527,8 +527,6 @@ pub fn derive_decrypt_with_password( password: &[u8], aad: Option>, ) -> Result, JsValue> { - Ok( - derive_encrypt::KdfEncryptedData::try_from(data)? - .decrypt_with_password_and_aad(password, &aad.unwrap_or_default())?, - ) + Ok(derive_encrypt::KdfEncryptedData::try_from(data)? + .decrypt_with_password_and_aad(password, &aad.unwrap_or_default())?) } diff --git a/uniffi/devolutions-crypto-uniffi/src/derive_encrypt.rs b/uniffi/devolutions-crypto-uniffi/src/derive_encrypt.rs index b91d0505..9a6efa93 100644 --- a/uniffi/devolutions-crypto-uniffi/src/derive_encrypt.rs +++ b/uniffi/devolutions-crypto-uniffi/src/derive_encrypt.rs @@ -1,6 +1,6 @@ use crate::{CiphertextVersion, KeyDerivationVersion, Result}; use devolutions_crypto::derive_encrypt::KdfEncryptedData; -use devolutions_crypto::key_derivation::derive_key; +use devolutions_crypto::{Argon2, Pbkdf2}; #[uniffi::export(default(kdf_version = None, ct_version = None))] pub fn derive_encrypt_with_password( @@ -11,7 +11,12 @@ pub fn derive_encrypt_with_password( ) -> Result> { let kdf_version = kdf_version.unwrap_or(KeyDerivationVersion::Latest); let ct_version = ct_version.unwrap_or(CiphertextVersion::Latest); - let (_, params) = derive_key(password, kdf_version)?; + let params = match kdf_version { + KeyDerivationVersion::Latest | KeyDerivationVersion::V2 => Argon2::new().parameters(), + KeyDerivationVersion::V1 => Pbkdf2::new() + .parameters() + .expect("default PKBDF2 parameters shouldn't fail"), + }; Ok(devolutions_crypto::derive_encrypt::encrypt_with_password( data, password, params, ct_version, )? @@ -28,7 +33,13 @@ pub fn derive_encrypt_with_password_and_aad( ) -> Result> { let kdf_version = kdf_version.unwrap_or(KeyDerivationVersion::Latest); let ct_version = ct_version.unwrap_or(CiphertextVersion::Latest); - let (_, params) = derive_key(password, kdf_version)?; + let params = match kdf_version { + KeyDerivationVersion::Latest | KeyDerivationVersion::V2 => Argon2::new().parameters(), + KeyDerivationVersion::V1 => Pbkdf2::new() + .parameters() + .expect("default PKBDF2 parameters shouldn't fail"), + }; + Ok( devolutions_crypto::derive_encrypt::encrypt_with_password_and_aad( data, password, aad, params, ct_version, From 8466b0f4c8819c876eb0252dcd7c850bd2b0ec2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Duquette?= Date: Tue, 2 Jun 2026 15:06:24 -0400 Subject: [PATCH 6/7] codex review --- 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 f00b1b0e..3448520d 100644 --- a/ffi/src/lib.rs +++ b/ffi/src/lib.rs @@ -316,9 +316,11 @@ pub unsafe extern "C" fn DeriveEncryptData( let password = Zeroizing::new(slice::from_raw_parts(password, password_length).to_vec()); let result = slice::from_raw_parts_mut(result, result_length); - let derivation_parameters = match derive_key(&password, key_derivation_version) { - Ok((_, params)) => params, - Err(e) => return e.error_code(), + let derivation_parameters = match key_derivation_version { + KeyDerivationVersion::Latest | KeyDerivationVersion::V2 => Argon2::new().parameters(), + KeyDerivationVersion::V1 => Pbkdf2::new() + .parameters() + .expect("default PKBDF2 parameters shouldn't fail"), }; match encrypt_with_password_and_aad( From 29ec36fca50327227d0885eb927b32b2fe67dfba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Duquette?= Date: Tue, 2 Jun 2026 15:41:26 -0400 Subject: [PATCH 7/7] review comments --- ffi/src/lib.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/ffi/src/lib.rs b/ffi/src/lib.rs index 3448520d..e42d34e3 100644 --- a/ffi/src/lib.rs +++ b/ffi/src/lib.rs @@ -254,6 +254,10 @@ pub extern "C" fn DeriveEncryptSize( } }; + // The length is calculated based on these values: + // 8 = KdfEncryptedData header + // 4 = u32 derivation_parameters length prefix + // 4 = u32 ciphertext length prefix (8 + 4 + derivation_parameters_size + 4 + ciphertext_size as usize) as i64 }