diff --git a/cli/src/main.rs b/cli/src/main.rs index c3e24516..8acbdd17 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,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::PrintHeader { data } => print_header(data), } } @@ -258,6 +290,42 @@ 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 +352,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 +610,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..e42d34e3 100644 --- a/ffi/src/lib.rs +++ b/ffi/src/lib.rs @@ -21,16 +21,18 @@ 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, }; -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::KeyDerivationVersion; use devolutions_crypto::OnlineCiphertextVersion; use devolutions_crypto::{ signature, @@ -221,6 +223,183 @@ 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 + } + }; + + // 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 +} + +/// 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 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( + 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..9cc26759 100644 --- a/python/src/lib.rs +++ b/python/src/lib.rs @@ -5,9 +5,9 @@ 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}; use devolutions_crypto::{ key, key::{PrivateKey, PublicKey, SecretKey}, @@ -17,7 +17,10 @@ use devolutions_crypto::{ signing_key, signing_key::{SigningKeyPair, SigningPublicKey}, }; -use devolutions_crypto::{CiphertextVersion, KeyVersion, SignatureVersion, SigningKeyVersion}; +use devolutions_crypto::{Argon2, Argon2Parameters, Pbkdf2}; +use devolutions_crypto::{ + CiphertextVersion, KeyDerivationVersion, KeyVersion, SignatureVersion, SigningKeyVersion, +}; enum DevolutionsCryptoError { DevolutionsCrypto(Error), @@ -345,6 +348,54 @@ 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 = 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(); + 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 +428,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..f9c48d6e --- /dev/null +++ b/src/derive_encrypt/kdf_encrypted_data_v1.rs @@ -0,0 +1,75 @@ +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 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()); + + 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..376acac2 --- /dev/null +++ b/src/derive_encrypt/mod.rs @@ -0,0 +1,284 @@ +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, Error, Header, HeaderType, KdfEncryptedDataVersion, 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. +/// * `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().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"); +/// ``` +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.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::key_derivation::Argon2; + use crate::utils::validate_header; + use crate::Pbkdf2; + + 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/mod.rs b/src/key_derivation/mod.rs index 6bb7301f..ad34f027 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..5239d074 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().parameters(); +//! 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; @@ -263,6 +295,7 @@ pub use argon2::Version as Argon2Version; pub use argon2parameters::defaults as argon2parameters_defaults; pub use argon2parameters::Argon2Parameters; pub use argon2parameters::Argon2ParametersBuilder; +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}; diff --git a/src/utils.rs b/src/utils.rs index 54ffd380..15e3ac9f 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,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() + } } } diff --git a/src/wasm.rs b/src/wasm.rs index c9f17f69..22a70e15 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,32 @@ 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, + version: 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, + version.unwrap_or(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/uniffi/devolutions-crypto-uniffi/src/derive_encrypt.rs b/uniffi/devolutions-crypto-uniffi/src/derive_encrypt.rs new file mode 100644 index 00000000..9a6efa93 --- /dev/null +++ b/uniffi/devolutions-crypto-uniffi/src/derive_encrypt.rs @@ -0,0 +1,63 @@ +use crate::{CiphertextVersion, KeyDerivationVersion, Result}; +use devolutions_crypto::derive_encrypt::KdfEncryptedData; +use devolutions_crypto::{Argon2, Pbkdf2}; + +#[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 = 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, + )? + .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 = 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, + )? + .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..c6b64f3a 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, Error as DevolutionsCryptoError, KdfEncryptedDataVersion, + 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 { 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..ee77fb1c --- /dev/null +++ b/wrappers/wasm/demo/src/app/derive-encrypt/derive-encrypt.component.html @@ -0,0 +1,154 @@ + +
+ + 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..702fc309 --- /dev/null +++ b/wrappers/wasm/demo/src/app/derive-encrypt/derive-encrypt.component.ts @@ -0,0 +1,134 @@ +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'; + cipherAlgorithm: 'xchacha20' | 'aes' = 'xchacha20'; + + 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: '' }); + } + + setCipherAlgorithm(cipher: 'xchacha20' | 'aes') { + this.cipherAlgorithm = cipher; + 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 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}` }); + } + } + + 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..60eb5e24 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 + Ciphertext' }, }; 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..198107ee 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, version?: CiphertextVersion): Uint8Array { + return devolutionsCrypto.deriveEncryptWithPassword(data, password, aad, params, version); +} + +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) + }) +}) +