Skip to main content

shadow_crypt_shell/decryption/
workflow.rs

1use rayon::prelude::*;
2use shadow_crypt_core::{
3    memory::SecureString,
4    progress::ProgressCounter,
5    report::DecryptionReport,
6    vault::{MAX_HEADER_LEN, ParsedFile},
7};
8
9use crate::{
10    decryption::{
11        file::{DecryptionInput, DecryptionInputFile},
12        file_ops::stream_decrypt_file,
13    },
14    errors::{WorkflowError, WorkflowResult},
15    kdf::derive_untrusted_key,
16    ui::{display_decryption_success, display_error, display_progress},
17    utils::read_n_bytes_from_file,
18};
19
20pub fn run_workflow(input: DecryptionInput) -> WorkflowResult<()> {
21    let total = input.files.len();
22    let counter = ProgressCounter::new(total as u64);
23
24    // Process files in parallel using rayon
25    let failures: Vec<WorkflowError> = input
26        .files
27        .par_iter()
28        .filter_map(|input_file| {
29            let result = process_file_decryption(
30                input_file.to_owned(),
31                &input.password,
32                &input.output_dir,
33                input.force,
34            )
35            .map_err(|e| WorkflowError::per_file(&input_file.filename, e));
36            counter.increment();
37            if !input.quiet {
38                display_progress(&counter);
39            }
40            match result {
41                Ok(report) => {
42                    if !input.quiet {
43                        display_decryption_success(&report);
44                    }
45                    None
46                }
47                Err(e) => {
48                    display_error(&e);
49                    Some(e)
50                }
51            }
52        })
53        .collect();
54
55    if !failures.is_empty() {
56        let message = format!("{} of {} file(s) failed to decrypt", failures.len(), total);
57        // Distinguish "everything failed to authenticate" (wrong password /
58        // corruption) so scripts get a dedicated exit code.
59        return Err(if failures.iter().all(|e| e.is_authentication_failure()) {
60            WorkflowError::Authentication(message)
61        } else {
62            WorkflowError::Decryption(message)
63        });
64    }
65
66    Ok(())
67}
68
69fn process_file_decryption(
70    file: DecryptionInputFile,
71    password: &SecureString,
72    output_dir: &std::path::Path,
73    force: bool,
74) -> WorkflowResult<DecryptionReport> {
75    let start_time = std::time::Instant::now();
76
77    // Only the header is read up front; the content is streamed afterwards,
78    // so memory stays bounded regardless of file size. ParsedFile dispatches
79    // to the file's own format version internally; this workflow is
80    // version-agnostic.
81    let header_bytes = read_n_bytes_from_file(&file.path, MAX_HEADER_LEN)?;
82    let parsed = ParsedFile::parse(header_bytes.as_slice())?;
83    let key = derive_untrusted_key(&parsed, password)?;
84
85    // Decrypting the metadata also verifies the password before any output
86    // file is created.
87    let metadata = parsed.decrypt_metadata(&key)?;
88    let output_file = stream_decrypt_file(&file, &parsed, &key, &metadata, output_dir, force)?;
89
90    let duration = start_time.elapsed();
91
92    Ok(DecryptionReport::new(
93        file.filename,
94        output_file.filename,
95        duration,
96        parsed.algorithm(),
97    ))
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103    use crate::kdf::MAX_KDF_MEMORY_KIB;
104    use shadow_crypt_core::{
105        file::PlaintextFile,
106        memory::{SecureBytes, SecureString},
107        profile::SecurityProfile,
108        v1, v2,
109    };
110
111    /// The parse → guarded derive → decrypt chain exactly as the workflow
112    /// runs it.
113    fn decrypt(bytes: &[u8], password: &SecureString) -> WorkflowResult<PlaintextFile> {
114        let parsed = ParsedFile::parse(bytes)?;
115        let key = derive_untrusted_key(&parsed, password)?;
116        Ok(parsed.decrypt(&key)?)
117    }
118
119    #[test]
120    fn test_v1_oversized_kdf_params_rejected_before_derivation() {
121        // A crafted v1 file claiming huge KDF costs must be rejected before
122        // any derivation is attempted.
123        let kdf_params = v1::key::KeyDerivationParams::new(MAX_KDF_MEMORY_KIB + 1, 1, 1, 32);
124        let header =
125            v1::header::FileHeader::new([0u8; 16], kdf_params, [0u8; 24], [0u8; 24], vec![1, 2, 3]);
126        let mut bytes = header.serialize();
127        bytes.extend_from_slice(b"ciphertext");
128
129        let password = SecureString::new("pw".to_string());
130        assert!(decrypt(&bytes, &password).is_err());
131    }
132
133    #[test]
134    fn test_v2_oversized_kdf_params_rejected_before_derivation() {
135        let kdf_params = v2::key::KeyDerivationParams::new(u32::MAX, 1, 1, 32);
136        let header =
137            v2::header::FileHeader::new([0u8; 16], kdf_params, [0u8; 24], [0u8; 24], vec![1, 2, 3])
138                .unwrap();
139        let mut bytes = header.serialize();
140        bytes.extend_from_slice(b"ciphertext");
141
142        let password = SecureString::new("pw".to_string());
143        assert!(decrypt(&bytes, &password).is_err());
144    }
145
146    #[test]
147    fn test_v2_swapped_ciphertext_pairs_fail_decryption() {
148        // End-to-end version of the swap attack: encrypt filename + content
149        // under one key, then build a file whose filename slot holds the
150        // content pair and vice versa. v2 must refuse to decrypt it.
151        let password = SecureString::new("testpassword".to_string());
152        let salt = [1u8; 16];
153        let kdf_params = v2::key::KeyDerivationParams::from(SecurityProfile::Test);
154        let content_nonce = [2u8; 24];
155        let filename_nonce = [3u8; 24];
156
157        let (key, _) = kdf_params
158            .derive_key(password.as_str().as_bytes(), &salt)
159            .unwrap();
160        let binding =
161            v2::header::HeaderBinding::new(&salt, &kdf_params, &content_nonce, &filename_nonce);
162
163        let (filename_ct, _) = v2::crypt::encrypt_bytes(
164            b"name.txt",
165            key.as_bytes(),
166            &filename_nonce,
167            &binding.aad(v2::header::AadPurpose::Filename),
168        )
169        .unwrap();
170        let (content_ct, _) = v2::crypt::encrypt_bytes(
171            b"content",
172            key.as_bytes(),
173            &content_nonce,
174            &binding.aad(v2::header::AadPurpose::Content),
175        )
176        .unwrap();
177
178        // Swap: content pair goes into the filename slot, filename pair
179        // becomes the content. Nonces swap along with the ciphertexts, which
180        // is exactly what made this pass undetected in v1.
181        let swapped_header = v2::header::FileHeader::new(
182            salt,
183            kdf_params,
184            filename_nonce, // content slot now uses the filename nonce
185            content_nonce,  // filename slot now uses the content nonce
186            content_ct,     // filename slot holds the content ciphertext
187        )
188        .unwrap();
189        let mut bytes = swapped_header.serialize();
190        bytes.extend_from_slice(&filename_ct);
191
192        assert!(decrypt(&bytes, &password).is_err());
193    }
194
195    #[test]
196    fn test_v2_round_trip_via_raw_bytes() {
197        let password = SecureString::new("testpassword".to_string());
198        let salt = [1u8; 16];
199        let kdf_params = v2::key::KeyDerivationParams::from(SecurityProfile::Test);
200
201        let (key, _) = kdf_params
202            .derive_key(password.as_str().as_bytes(), &salt)
203            .unwrap();
204
205        let plaintext_file = PlaintextFile::new(
206            SecureString::new("name.txt".to_string()),
207            SecureBytes::new(b"content".to_vec()),
208        );
209        let sealed = v2::file::EncryptedFile::seal(
210            &plaintext_file,
211            &key,
212            kdf_params,
213            salt,
214            [2u8; 24],
215            [3u8; 24],
216        )
217        .unwrap();
218
219        let decrypted = decrypt(&sealed.to_bytes(), &password).unwrap();
220        assert_eq!(decrypted.filename().as_str(), "name.txt");
221        assert_eq!(decrypted.content().as_slice(), b"content");
222    }
223}