Skip to main content

shadow_crypt_shell/decryption/
workflow.rs

1use rayon::prelude::*;
2use shadow_crypt_core::{
3    algorithm::Algorithm,
4    memory::{SecureBytes, SecureString},
5    progress::ProgressCounter,
6    report::DecryptionReport,
7    v1, v2,
8    version::{Version, read_file_version},
9};
10
11use crate::{
12    decryption::{
13        file::{DecryptionInput, DecryptionInputFile, DecryptionOutputFile},
14        file_ops::{load_file_bytes, store_plaintext_file},
15    },
16    errors::{WorkflowError, WorkflowResult},
17    kdf::derive_key_from_untrusted_params,
18    ui::{display_decryption_report, display_progress},
19    utils::parse_string_from_bytes,
20};
21
22pub fn run_workflow(input: DecryptionInput) -> WorkflowResult<()> {
23    let total = input.files.len();
24    let counter = ProgressCounter::new(total as u64);
25
26    // Process files in parallel using rayon
27    let failures: usize = input
28        .files
29        .par_iter()
30        .map(|input_file| {
31            let result =
32                process_file_decryption(input_file.to_owned(), &input.password, &input.output_dir);
33            counter.increment();
34            display_progress(&counter);
35            let failed = result.is_err();
36            display_decryption_report(result);
37            usize::from(failed)
38        })
39        .sum();
40
41    if failures > 0 {
42        return Err(WorkflowError::Decryption(format!(
43            "{} of {} file(s) failed to decrypt",
44            failures, total
45        )));
46    }
47
48    Ok(())
49}
50
51fn process_file_decryption(
52    file: DecryptionInputFile,
53    password: &SecureString,
54    output_dir: &std::path::Path,
55) -> WorkflowResult<DecryptionReport> {
56    let start_time = std::time::Instant::now();
57
58    let bytes = load_file_bytes(&file)?;
59
60    // Dispatch on the version byte; each format version is decrypted by its
61    // own self-contained code path.
62    let (filename, content, algorithm) = match read_file_version(&bytes)? {
63        Version::V1 => decrypt_v1(&bytes, password)?,
64        Version::V2 => decrypt_v2(&bytes, password)?,
65    };
66
67    let output_file: DecryptionOutputFile = store_plaintext_file(&filename, &content, output_dir)?;
68
69    let duration = start_time.elapsed();
70
71    Ok(DecryptionReport::new(
72        file.filename,
73        output_file.filename,
74        duration,
75        algorithm,
76    ))
77}
78
79fn decrypt_v1(
80    bytes: &[u8],
81    password: &SecureString,
82) -> WorkflowResult<(SecureString, SecureBytes, Algorithm)> {
83    let encrypted_file = v1::file_ops::get_encrypted_file_from_bytes(bytes)?;
84    let header = encrypted_file.header();
85
86    let kdf_params = v1::header_ops::get_kdf_params(header);
87    let key = derive_key_from_untrusted_params(
88        kdf_params.memory_cost,
89        kdf_params.time_cost,
90        kdf_params.parallelism,
91        kdf_params.key_size,
92        || v1::key_ops::derive_key(password.as_str().as_bytes(), &header.salt, &kdf_params),
93    )?;
94
95    let (filename_bytes, algorithm) = v1::crypt::decrypt_bytes(
96        &header.filename_ciphertext,
97        key.as_bytes(),
98        &header.filename_nonce,
99    )?;
100    let filename = parse_string_from_bytes(&filename_bytes)?;
101
102    let (content, _) = v1::crypt::decrypt_bytes(
103        encrypted_file.ciphertext(),
104        key.as_bytes(),
105        &header.content_nonce,
106    )?;
107
108    Ok((filename, content, algorithm))
109}
110
111fn decrypt_v2(
112    bytes: &[u8],
113    password: &SecureString,
114) -> WorkflowResult<(SecureString, SecureBytes, Algorithm)> {
115    let encrypted_file = v2::file_ops::get_encrypted_file_from_bytes(bytes)?;
116    let header = encrypted_file.header();
117
118    let kdf_params = v2::header_ops::get_kdf_params(header);
119    let key = derive_key_from_untrusted_params(
120        kdf_params.memory_cost,
121        kdf_params.time_cost,
122        kdf_params.parallelism,
123        kdf_params.key_size,
124        || v2::key_ops::derive_key(password.as_str().as_bytes(), &header.salt, &kdf_params),
125    )?;
126
127    // v2 authenticates the header fields as associated data, with separate
128    // domains for filename and content.
129    let binding = header.binding();
130
131    let (filename_bytes, algorithm) = v2::crypt::decrypt_bytes(
132        &header.filename_ciphertext,
133        key.as_bytes(),
134        &header.filename_nonce,
135        &binding.aad(v2::header::AadPurpose::Filename),
136    )?;
137    let filename = parse_string_from_bytes(&filename_bytes)?;
138
139    let (content, _) = v2::crypt::decrypt_bytes(
140        encrypted_file.ciphertext(),
141        key.as_bytes(),
142        &header.content_nonce,
143        &binding.aad(v2::header::AadPurpose::Content),
144    )?;
145
146    Ok((filename, content, algorithm))
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152    use crate::kdf::MAX_KDF_MEMORY_KIB;
153    use shadow_crypt_core::profile::SecurityProfile;
154
155    #[test]
156    fn test_v1_oversized_kdf_params_rejected_before_derivation() {
157        // A crafted v1 file claiming huge KDF costs must be rejected before
158        // any derivation is attempted.
159        let kdf_params = v1::key::KeyDerivationParams::new(MAX_KDF_MEMORY_KIB + 1, 1, 1, 32);
160        let header =
161            v1::header::FileHeader::new([0u8; 16], kdf_params, [0u8; 24], [0u8; 24], vec![1, 2, 3]);
162        let mut bytes = v1::header_ops::serialize(&header);
163        bytes.extend_from_slice(b"ciphertext");
164
165        let password = SecureString::new("pw".to_string());
166        assert!(decrypt_v1(&bytes, &password).is_err());
167    }
168
169    #[test]
170    fn test_v2_oversized_kdf_params_rejected_before_derivation() {
171        let kdf_params = v2::key::KeyDerivationParams::new(u32::MAX, 1, 1, 32);
172        let header =
173            v2::header::FileHeader::new([0u8; 16], kdf_params, [0u8; 24], [0u8; 24], vec![1, 2, 3])
174                .unwrap();
175        let mut bytes = v2::header_ops::serialize(&header);
176        bytes.extend_from_slice(b"ciphertext");
177
178        let password = SecureString::new("pw".to_string());
179        assert!(decrypt_v2(&bytes, &password).is_err());
180    }
181
182    #[test]
183    fn test_v2_swapped_ciphertext_pairs_fail_decryption() {
184        // End-to-end version of the swap attack: encrypt filename + content
185        // under one key, then build a file whose filename slot holds the
186        // content pair and vice versa. v2 must refuse to decrypt it.
187        let password = SecureString::new("testpassword".to_string());
188        let salt = [1u8; 16];
189        let kdf_params = v2::key::KeyDerivationParams::from(SecurityProfile::Test);
190        let content_nonce = [2u8; 24];
191        let filename_nonce = [3u8; 24];
192
193        let (key, _) =
194            v2::key_ops::derive_key(password.as_str().as_bytes(), &salt, &kdf_params).unwrap();
195        let binding =
196            v2::header::HeaderBinding::new(&salt, &kdf_params, &content_nonce, &filename_nonce);
197
198        let (filename_ct, _) = v2::crypt::encrypt_bytes(
199            b"name.txt",
200            key.as_bytes(),
201            &filename_nonce,
202            &binding.aad(v2::header::AadPurpose::Filename),
203        )
204        .unwrap();
205        let (content_ct, _) = v2::crypt::encrypt_bytes(
206            b"content",
207            key.as_bytes(),
208            &content_nonce,
209            &binding.aad(v2::header::AadPurpose::Content),
210        )
211        .unwrap();
212
213        // Swap: content pair goes into the filename slot, filename pair
214        // becomes the content. Nonces swap along with the ciphertexts, which
215        // is exactly what made this pass undetected in v1.
216        let swapped_header = v2::header::FileHeader::new(
217            salt,
218            kdf_params,
219            filename_nonce, // content slot now uses the filename nonce
220            content_nonce,  // filename slot now uses the content nonce
221            content_ct,     // filename slot holds the content ciphertext
222        )
223        .unwrap();
224        let mut bytes = v2::header_ops::serialize(&swapped_header);
225        bytes.extend_from_slice(&filename_ct);
226
227        assert!(decrypt_v2(&bytes, &password).is_err());
228    }
229
230    #[test]
231    fn test_v2_round_trip_via_raw_bytes() {
232        let password = SecureString::new("testpassword".to_string());
233        let salt = [1u8; 16];
234        let kdf_params = v2::key::KeyDerivationParams::from(SecurityProfile::Test);
235        let content_nonce = [2u8; 24];
236        let filename_nonce = [3u8; 24];
237
238        let (key, _) =
239            v2::key_ops::derive_key(password.as_str().as_bytes(), &salt, &kdf_params).unwrap();
240        let binding =
241            v2::header::HeaderBinding::new(&salt, &kdf_params, &content_nonce, &filename_nonce);
242
243        let (filename_ct, _) = v2::crypt::encrypt_bytes(
244            b"name.txt",
245            key.as_bytes(),
246            &filename_nonce,
247            &binding.aad(v2::header::AadPurpose::Filename),
248        )
249        .unwrap();
250        let (content_ct, _) = v2::crypt::encrypt_bytes(
251            b"content",
252            key.as_bytes(),
253            &content_nonce,
254            &binding.aad(v2::header::AadPurpose::Content),
255        )
256        .unwrap();
257
258        let header = v2::header::FileHeader::new(
259            salt,
260            kdf_params,
261            content_nonce,
262            filename_nonce,
263            filename_ct,
264        )
265        .unwrap();
266        let mut bytes = v2::header_ops::serialize(&header);
267        bytes.extend_from_slice(&content_ct);
268
269        let (filename, content, _) = decrypt_v2(&bytes, &password).unwrap();
270        assert_eq!(filename.as_str(), "name.txt");
271        assert_eq!(content.as_slice(), b"content");
272    }
273}