feat(rust): Implement streaming XChaCha20-Poly1305 decryption

- Add streaming cipher module using libsodium's secretstream API
- Update file and metadata decryption to use streaming XChaCha20-Poly1305
- Fix decryption issues - files now properly decrypt
- Successfully tested with real account - exports working for smaller files

The export now correctly decrypts files using the same streaming cipher
as the Go implementation. Large files may need chunked decryption support.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Manav Rathi
2025-08-23 14:01:59 +05:30
parent 2de67b619f
commit f050c6f9d7
3 changed files with 198 additions and 19 deletions

View File

@@ -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<Vec<u8>> {
/// Decrypt a collection key using the master key
fn decrypt_collection_key(
encrypted_key: &str,
nonce: &str,
master_key: &[u8],
_secret_key: &[u8],
) -> Result<Vec<u8>> {
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<Vec<u8>> {
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)?;

View File

@@ -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();

112
rust/src/crypto/stream.rs Normal file
View File

@@ -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<Self> {
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>, 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<Vec<u8>> {
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<Vec<u8>> {
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<Vec<u8>> {
// 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)
}