diff --git a/rust/src/commands/export.rs b/rust/src/commands/export.rs index d46dbde0c7..b82d12fb80 100644 --- a/rust/src/commands/export.rs +++ b/rust/src/commands/export.rs @@ -1,7 +1,7 @@ use crate::Result; use crate::api::client::ApiClient; use crate::api::methods::ApiMethods; -use crate::crypto::{decrypt_chacha, init as crypto_init}; +use crate::crypto::{decrypt_stream, init as crypto_init, secret_box_open}; use crate::models::{account::Account, metadata::FileMetadata}; use crate::storage::Storage; use base64::Engine; @@ -104,6 +104,9 @@ async fn export_account(storage: &Storage, account: &Account) -> Result<()> { // Master key is already raw bytes, no need to decode let master_key = &secrets.master_key; + // We'll also need the secret key for decrypting collection keys + let secret_key = &secrets.secret_key; + // Fetch and export files for each collection println!("\nFetching files..."); let mut total_files = 0; @@ -117,6 +120,20 @@ async fn export_account(storage: &Storage, account: &Account) -> Result<()> { println!("Processing collection: {}", collection.id); + // Decrypt collection key + let collection_key = match decrypt_collection_key( + &collection.encrypted_key, + &collection.key_decryption_nonce, + master_key, + secret_key, + ) { + Ok(key) => key, + Err(e) => { + log::error!("Failed to decrypt collection key: {e}"); + continue; + } + }; + let mut has_more = true; let mut since_time = 0i64; @@ -143,12 +160,29 @@ async fn export_account(storage: &Storage, account: &Account) -> Result<()> { since_time = file.updation_time; } - // Decrypt the file key first to get metadata - let file_key = - decrypt_file_key(&file.encrypted_key, &file.key_decryption_nonce, master_key)?; + // Decrypt the file key using the collection key + let file_key = match decrypt_file_key( + &file.encrypted_key, + &file.key_decryption_nonce, + &collection_key, + ) { + Ok(key) => key, + Err(e) => { + log::error!("Failed to decrypt key for file {}: {}", file.id, e); + log::debug!("encrypted_key: {}", &file.encrypted_key); + log::debug!("key_decryption_nonce: {}", &file.key_decryption_nonce); + continue; + } + }; // Decrypt metadata to get original filename - let metadata = decrypt_file_metadata(&file, &file_key)?; + let metadata = match decrypt_file_metadata(&file, &file_key) { + Ok(meta) => meta, + Err(e) => { + log::warn!("Failed to decrypt metadata for file {}: {}", file.id, e); + None + } + }; // Generate export path with original filename from metadata let file_path = @@ -171,16 +205,30 @@ async fn export_account(storage: &Storage, account: &Account) -> Result<()> { // Download encrypted file let encrypted_data = api.download_file(&account.email, file.id).await?; - // Extract file decryption header (first 24 bytes are the nonce) - if encrypted_data.len() < 24 { - log::error!("File {} has invalid encrypted data", file.id); - continue; - } - let file_nonce = &encrypted_data[0..24]; - let ciphertext = &encrypted_data[24..]; + // The file nonce/header is stored separately in the API response + let file_nonce = match base64::engine::general_purpose::STANDARD + .decode(&file.file.decryption_header) + { + Ok(nonce) => nonce, + Err(e) => { + log::error!("Failed to decode file nonce for file {}: {}", file.id, e); + continue; + } + }; - // Decrypt the file - let decrypted = decrypt_chacha(ciphertext, file_nonce, &file_key)?; + // Decrypt the file data using streaming XChaCha20-Poly1305 + let decrypted = match decrypt_stream(&encrypted_data, &file_nonce, &file_key) { + Ok(data) => data, + Err(e) => { + log::error!("Failed to decrypt file {}: {}", file.id, e); + log::debug!( + "File size: {}, header length: {}", + encrypted_data.len(), + file_nonce.len() + ); + continue; + } + }; // Write decrypted file let mut file_handle = fs::File::create(&file_path).await?; @@ -268,14 +316,31 @@ fn generate_export_path( Ok(path) } -/// Decrypt a file key using the master key -fn decrypt_file_key(encrypted_key: &str, nonce: &str, master_key: &[u8]) -> Result> { +/// Decrypt a collection key using the master key +fn decrypt_collection_key( + encrypted_key: &str, + nonce: &str, + master_key: &[u8], + _secret_key: &[u8], +) -> Result> { use base64::engine::general_purpose::STANDARD as BASE64; let encrypted_bytes = BASE64.decode(encrypted_key)?; let nonce_bytes = BASE64.decode(nonce)?; - decrypt_chacha(&encrypted_bytes, &nonce_bytes, master_key) + // Collection keys are encrypted with secret_box (XSalsa20-Poly1305) using master key + secret_box_open(&encrypted_bytes, &nonce_bytes, master_key) +} + +/// Decrypt a file key using the collection key +fn decrypt_file_key(encrypted_key: &str, nonce: &str, collection_key: &[u8]) -> Result> { + use base64::engine::general_purpose::STANDARD as BASE64; + + let encrypted_bytes = BASE64.decode(encrypted_key)?; + let nonce_bytes = BASE64.decode(nonce)?; + + // File keys are encrypted with secret_box (XSalsa20-Poly1305) using collection key + secret_box_open(&encrypted_bytes, &nonce_bytes, collection_key) } /// Generate a fallback filename when metadata is not available @@ -329,8 +394,8 @@ fn decrypt_file_metadata( let encrypted_bytes = BASE64.decode(encrypted_data)?; let header_bytes = BASE64.decode(&file.metadata.decryption_header)?; - // Decrypt the metadata - let decrypted = decrypt_chacha(&encrypted_bytes, &header_bytes, file_key)?; + // Decrypt the metadata using streaming XChaCha20-Poly1305 + let decrypted = decrypt_stream(&encrypted_bytes, &header_bytes, file_key)?; // Parse JSON metadata let metadata: FileMetadata = serde_json::from_slice(&decrypted)?; diff --git a/rust/src/crypto/mod.rs b/rust/src/crypto/mod.rs index b7872d9ef3..619c7bb7a2 100644 --- a/rust/src/crypto/mod.rs +++ b/rust/src/crypto/mod.rs @@ -6,12 +6,14 @@ use std::sync::Once; mod argon; mod chacha; mod kdf; +mod stream; pub use argon::derive_argon_key; pub use chacha::{ decrypt_chacha, encrypt_chacha, sealed_box_open, secret_box_open, secret_box_seal, }; pub use kdf::derive_login_key; +pub use stream::{StreamDecryptor, decrypt_file_data, decrypt_stream}; static INIT: Once = Once::new(); diff --git a/rust/src/crypto/stream.rs b/rust/src/crypto/stream.rs new file mode 100644 index 0000000000..8a972f474a --- /dev/null +++ b/rust/src/crypto/stream.rs @@ -0,0 +1,112 @@ +use crate::{Error, Result}; +use libsodium_sys as sodium; + +/// Tag indicating this is the final message in the stream +#[allow(dead_code)] +pub const TAG_FINAL: u8 = 0x03; +/// Tag for regular messages +#[allow(dead_code)] +pub const TAG_MESSAGE: u8 = 0x00; + +/// XChaCha20-Poly1305 streaming decryptor +pub struct StreamDecryptor { + state: Box<[u8]>, +} + +impl StreamDecryptor { + fn state_bytes() -> usize { + unsafe { sodium::crypto_secretstream_xchacha20poly1305_statebytes() } + } + + /// Create a new stream decryptor from key and header + pub fn new(key: &[u8], header: &[u8]) -> Result { + if key.len() != sodium::crypto_secretstream_xchacha20poly1305_KEYBYTES as usize { + return Err(Error::Crypto(format!( + "Invalid key length: expected {}, got {}", + sodium::crypto_secretstream_xchacha20poly1305_KEYBYTES, + key.len() + ))); + } + + if header.len() != sodium::crypto_secretstream_xchacha20poly1305_HEADERBYTES as usize { + return Err(Error::Crypto(format!( + "Invalid header length: expected {}, got {}", + sodium::crypto_secretstream_xchacha20poly1305_HEADERBYTES, + header.len() + ))); + } + + let mut state = vec![0u8; Self::state_bytes()].into_boxed_slice(); + + unsafe { + let ret = sodium::crypto_secretstream_xchacha20poly1305_init_pull( + state.as_mut_ptr() as *mut sodium::crypto_secretstream_xchacha20poly1305_state, + header.as_ptr(), + key.as_ptr(), + ); + + if ret != 0 { + return Err(Error::Crypto( + "Failed to initialize stream decryptor".into(), + )); + } + } + + Ok(StreamDecryptor { state }) + } + + /// Pull (decrypt) a message from the stream + pub fn pull(&mut self, ciphertext: &[u8]) -> Result<(Vec, u8)> { + if ciphertext.len() < sodium::crypto_secretstream_xchacha20poly1305_ABYTES as usize { + return Err(Error::Crypto("Ciphertext too short".into())); + } + + let mut plaintext = vec![ + 0u8; + ciphertext.len() + - sodium::crypto_secretstream_xchacha20poly1305_ABYTES as usize + ]; + let mut plaintext_len: u64 = 0; + let mut tag: u8 = 0; + + unsafe { + let ret = sodium::crypto_secretstream_xchacha20poly1305_pull( + self.state.as_mut_ptr() as *mut sodium::crypto_secretstream_xchacha20poly1305_state, + plaintext.as_mut_ptr(), + &mut plaintext_len, + &mut tag, + ciphertext.as_ptr(), + ciphertext.len() as u64, + std::ptr::null(), + 0, + ); + + if ret != 0 { + return Err(Error::Crypto("Failed to decrypt stream chunk".into())); + } + } + + plaintext.truncate(plaintext_len as usize); + Ok((plaintext, tag)) + } + + /// Decrypt an entire message at once (non-streaming) + pub fn decrypt_all(key: &[u8], header: &[u8], ciphertext: &[u8]) -> Result> { + let mut decryptor = Self::new(key, header)?; + let (plaintext, _tag) = decryptor.pull(ciphertext)?; + Ok(plaintext) + } +} + +/// Decrypt data using streaming XChaCha20-Poly1305 +/// This is for single-chunk decryption (most common case for files) +pub fn decrypt_stream(ciphertext: &[u8], header: &[u8], key: &[u8]) -> Result> { + StreamDecryptor::decrypt_all(key, header, ciphertext) +} + +/// Decrypt file data from memory using streaming cipher +pub fn decrypt_file_data(encrypted_data: &[u8], header: &[u8], key: &[u8]) -> Result> { + // For large files, the Go implementation uses streaming with chunks + // For now, we'll decrypt the whole file at once which works for most files + decrypt_stream(encrypted_data, header, key) +}