Skip to main content

crypt/
crypt.rs

1use std::{env, fs, process};
2
3use crypto::{Aead, Xof, aes::Aes256Gcm, chacha::ChaCha20Blake3, sha3::Shake256};
4use zeroize::{Zeroize, Zeroizing};
5
6const KEY_LENGTH: usize = 32;
7const NONCE_SEED_LENGTH: usize = 32;
8
9const KDF_INFO_CHACHA20_BLAKE3_KEY: &str = "crypt ChaCha20-BLAKE3 key";
10const KDF_INFO_CHACHA20_BLAKE3_NONCE: &str = "crypt ChaCha20-BLAKE3 nonce";
11const CHACHA20_BLAKE3_NONCE_LENGTH: usize = 32;
12
13const KDF_INFO_AES_KEY: &str = "crypt AES-256-GCM key";
14const KDF_INFO_AES_NONCE: &str = "crypt AES-256-GCM nonce";
15const AES_NONCE_LENGTH: usize = 12;
16
17const ARGON2_SALT_LENGTH: usize = 32;
18const ARGON2_ITERATIONS: u32 = 8;
19const ARGON2_MEMORY_KB: u32 = 1024 * 1024; // 1 GiB
20const ARGON2_LANES: u32 = 4;
21const KDF_INFO_ARGON2_SALT: &str = "crypt Argon2 salt";
22
23fn main() {
24    let args: Vec<String> = env::args().collect();
25    if args.len() != 4 {
26        print_help_and_exit(1);
27    }
28
29    let action = &args[1];
30    let file_in = &args[2];
31    let file_out = &args[3];
32
33    let (confirm_password, encrypt_mode) = match action.as_str() {
34        "encrypt" => (true, true),
35        "decrypt" => (false, false),
36        _ => print_help_and_exit(1),
37    };
38
39    let mut password = match ask_for_password(confirm_password) {
40        Ok(p) => p,
41        Err(e) => {
42            eprintln!("Error: {e}");
43            process::exit(1);
44        }
45    };
46
47    let fn_ptr: fn(&[u8], &[u8]) -> Result<Vec<u8>, String> = if encrypt_mode { encrypt } else { decrypt };
48    let result = process_file(password.as_bytes(), file_in, file_out, fn_ptr);
49    password.zeroize();
50
51    if let Err(e) = result {
52        eprintln!("Error: {e}");
53        process::exit(1);
54    }
55}
56
57fn process_file(
58    password: &[u8],
59    file_in: &str,
60    file_out: &str,
61    f: fn(&[u8], &[u8]) -> Result<Vec<u8>, String>,
62) -> Result<(), String> {
63    if file_in == file_out {
64        return Err("input file can't be the same as output file".to_string());
65    }
66
67    let mut data_in = fs::read(file_in).map_err(|e| format!("error reading [{file_in}]: {e}"))?;
68
69    let mut data_out = f(password, &data_in)?;
70
71    let write_result = fs::write(file_out, &data_out).map_err(|e| format!("error writing to [{file_out}]: {e}"));
72
73    data_in.zeroize();
74    data_out.zeroize();
75
76    write_result
77}
78
79/// Production Argon2id parameters used by the CLI.
80fn production_params() -> crypto::argon2::Params {
81    crypto::argon2::Params {
82        iterations: ARGON2_ITERATIONS,
83        memory: ARGON2_MEMORY_KB,
84        parallelism: ARGON2_LANES,
85    }
86}
87
88// Returns nonce_seed (32 bytes) || chacha20_blake3_ciphertext
89//
90// chacha20_blake3_nonce = derive_key(nonce_seed, "...", 24)
91// aes_nonce             = derive_key(nonce_seed, "...", 12)
92// argon2_salt           = derive_key(nonce_seed, "...", 32)
93fn encrypt(password: &[u8], plaintext: &[u8]) -> Result<Vec<u8>, String> {
94    encrypt_with_params(password, plaintext, &production_params())
95}
96
97/// Encrypt `plaintext` with `password` using explicit Argon2id parameters.
98fn encrypt_with_params(password: &[u8], plaintext: &[u8], params: &crypto::argon2::Params) -> Result<Vec<u8>, String> {
99    let nonce_seed: [u8; NONCE_SEED_LENGTH] = rand::random();
100
101    let chacha20_nonce = derive_key::<CHACHA20_BLAKE3_NONCE_LENGTH>(&nonce_seed, KDF_INFO_CHACHA20_BLAKE3_NONCE);
102    let aes_nonce = derive_key::<AES_NONCE_LENGTH>(&nonce_seed, KDF_INFO_AES_NONCE);
103    let argon2_salt = derive_key::<ARGON2_SALT_LENGTH>(&nonce_seed, KDF_INFO_ARGON2_SALT);
104
105    let root_key = argon2_derive_key(password, argon2_salt.as_slice(), params)?;
106
107    let aes_key = derive_key::<KEY_LENGTH>(root_key.as_slice(), KDF_INFO_AES_KEY);
108    let chacha20_key = derive_key::<KEY_LENGTH>(root_key.as_slice(), KDF_INFO_CHACHA20_BLAKE3_KEY);
109
110    // Encrypt inner layer with AES-256-GCM
111    let aes = Aes256Gcm::new(&aes_key);
112    let mut aes_buf = plaintext.to_vec();
113    let tag = aes.encrypt_in_place(&mut aes_buf, aes_nonce.as_slice(), &[]);
114    aes_buf.extend_from_slice(tag.as_ref());
115
116    // Encrypt outer layer with ChaCha20-BLAKE3
117    let cipher = ChaCha20Blake3::new(&*chacha20_key);
118    let outer_ciphertext = cipher.encrypt(&aes_buf, &*chacha20_nonce, &[]);
119
120    let mut result = Vec::with_capacity(NONCE_SEED_LENGTH + outer_ciphertext.len());
121    result.extend_from_slice(&nonce_seed);
122    result.extend_from_slice(&outer_ciphertext);
123
124    Ok(result)
125}
126
127fn decrypt(password: &[u8], ciphertext: &[u8]) -> Result<Vec<u8>, String> {
128    decrypt_with_params(password, ciphertext, &production_params())
129}
130
131/// Decrypt `ciphertext` with `password` using explicit Argon2id parameters.
132fn decrypt_with_params(password: &[u8], ciphertext: &[u8], params: &crypto::argon2::Params) -> Result<Vec<u8>, String> {
133    if ciphertext.len() < (NONCE_SEED_LENGTH + ChaCha20Blake3::TAG_SIZE) {
134        return Err("ciphertext is too short".to_string());
135    }
136
137    let nonce_seed = &ciphertext[..NONCE_SEED_LENGTH];
138    let ciphertext = &ciphertext[NONCE_SEED_LENGTH..];
139
140    let chacha20_nonce = derive_key::<CHACHA20_BLAKE3_NONCE_LENGTH>(&nonce_seed, KDF_INFO_CHACHA20_BLAKE3_NONCE);
141    let aes_nonce = derive_key::<AES_NONCE_LENGTH>(&nonce_seed, KDF_INFO_AES_NONCE);
142    let argon2_salt = derive_key::<ARGON2_SALT_LENGTH>(&nonce_seed, KDF_INFO_ARGON2_SALT);
143
144    let root_key = argon2_derive_key(password, argon2_salt.as_slice(), params)?;
145
146    let aes_key = derive_key::<KEY_LENGTH>(root_key.as_slice(), KDF_INFO_AES_KEY);
147    let chacha20_key = derive_key::<KEY_LENGTH>(root_key.as_slice(), KDF_INFO_CHACHA20_BLAKE3_KEY);
148
149    // Decrypt outer layer with ChaCha20-BLAKE3
150    let cipher = ChaCha20Blake3::new(&*chacha20_key);
151    let aes_ciphertext = cipher
152        .decrypt(ciphertext, &*chacha20_nonce, &[])
153        .map_err(|e| format!("error decrypting data with ChaCha20-BLAKE3: {e}"))?;
154
155    // Decrypt inner layer with AES-256-GCM
156    if aes_ciphertext.len() < Aes256Gcm::TAG_SIZE {
157        return Err("ciphertext is too short for AES-256-GCM tag".to_string());
158    }
159
160    let aes = Aes256Gcm::new(&aes_key);
161    let tag_pos = aes_ciphertext.len() - Aes256Gcm::TAG_SIZE;
162    let tag: [u8; 16] = aes_ciphertext[tag_pos..].try_into().unwrap();
163    let mut plaintext_buf = aes_ciphertext[..tag_pos].to_vec();
164    aes.decrypt_in_place(&mut plaintext_buf, aes_nonce.as_slice(), &[], &tag)
165        .map_err(|_| "error decrypting data with AES-256-GCM: authentication failed".to_string())?;
166
167    Ok(plaintext_buf)
168}
169
170fn derive_key<const N: usize>(root_key: &[u8], info: &str) -> Zeroizing<[u8; N]> {
171    let mut out = Zeroizing::new([0u8; N]);
172
173    let mut shake = Shake256::new();
174    shake.absorb(root_key);
175    shake.absorb(&(root_key.len() as u64).to_le_bytes());
176    shake.absorb(info.as_bytes());
177    shake.absorb(&(info.len() as u64).to_le_bytes());
178    shake.absorb(&(N as u64).to_le_bytes());
179    shake.squeeze(out.as_mut_slice());
180
181    return out;
182}
183
184fn argon2_derive_key(
185    password: &[u8],
186    salt: &[u8],
187    params: &crypto::argon2::Params,
188) -> Result<Zeroizing<[u8; KEY_LENGTH]>, String> {
189    let mut key = Zeroizing::new([0u8; KEY_LENGTH]);
190    crypto::argon2::derive_key(key.as_mut_slice(), password, salt, &[], &[], params)
191        .map_err(|e| format!("error deriving key with argon2: {e}"))?;
192
193    Ok(key)
194}
195
196fn ask_for_password(confirm: bool) -> Result<String, String> {
197    eprint!("Password: ");
198    let password = term::read_password().map_err(|e| format!("error reading password: {e}"))?;
199    eprintln!();
200
201    if password.is_empty() {
202        return Err("password is empty".to_string());
203    }
204
205    if confirm {
206        eprint!("Confirm Password: ");
207        let confirmation = term::read_password().map_err(|e| format!("error reading password confirmation: {e}"))?;
208        eprintln!();
209
210        if password != confirmation {
211            return Err("passwords don't match".to_string());
212        }
213    }
214
215    Ok(password)
216}
217
218fn print_help_and_exit(exit_code: i32) -> ! {
219    eprintln!("usage: crypt <encrypt|decrypt> <in> <out>");
220    process::exit(exit_code);
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226
227    struct TestCase {
228        password: &'static str,
229        data: &'static str,
230    }
231
232    fn test_cases() -> Vec<TestCase> {
233        vec![
234            TestCase {
235                password: "",
236                data: "",
237            },
238            TestCase {
239                password: "password",
240                data: "",
241            },
242            TestCase {
243                password: "",
244                data: "data",
245            },
246            TestCase {
247                password: "password",
248                data: "data",
249            },
250            TestCase {
251                password: "password",
252                // echo -n 'data' | shasum -a 512, repeated
253                data: "77c7ce9a5d86bb386d443bb96390faa120633158699c8844c30b13ab0bf92760b7e4416aea397db91b4ac0e5dd56b8ef7e4b066162ab1fdc088319ce6defc87677c7ce9a5d86bb386d443bb96390faa120633158699c8844c30b13ab0bf92760b7e4416aea397db91b4ac0e5dd56b8ef7e4b066162ab1fdc088319ce6defc87677c7ce9a5d86bb386d443bb96390faa120633158699c8844c30b13ab0bf92760b7e4416aea397db91b4ac0e5dd56b8ef7e4b066162ab1fdc088319ce6defc876",
254            },
255        ]
256    }
257
258    /// Lightweight Argon2id parameters so the test suite stays fast and
259    /// memory-friendly. The full production parameters are exercised by
260    /// `test_encrypt_decrypt_full_params`, which is ignored by default.
261    fn test_params() -> crypto::argon2::Params {
262        crypto::argon2::Params {
263            iterations: 2,
264            memory: 64,
265            parallelism: 1,
266        }
267    }
268
269    fn roundtrip(test: &TestCase, params: &crypto::argon2::Params, i: usize) {
270        let password = test.password.as_bytes();
271        let data = test.data.as_bytes();
272
273        let ciphertext = encrypt_with_params(password, data, params)
274            .unwrap_or_else(|e| panic!("error encrypting data [{}]: {}", i, e));
275
276        // Ciphertext must not equal plaintext
277        assert!(
278            ciphertext != data && (data.is_empty() || &ciphertext[..data.len()] != data),
279            "ciphertext == data for {}",
280            i
281        );
282
283        let plaintext = decrypt_with_params(password, &ciphertext, params)
284            .unwrap_or_else(|e| panic!("error decrypting data [{}]: {}", i, e));
285
286        // Wrong password must fail
287        let mut wrong_password = test.password.to_string();
288        wrong_password.push('1');
289        let ciphertext2 = ciphertext.clone();
290        let wrong_result = decrypt_with_params(wrong_password.as_bytes(), &ciphertext2, params);
291        assert!(
292            wrong_result.is_err(),
293            "expected error when using invalid password decrypting data for [{}]",
294            i
295        );
296
297        assert_eq!(
298            plaintext,
299            data,
300            "data ({}) != decrypted plaintext ({}) for {}",
301            test.data,
302            String::from_utf8_lossy(&plaintext),
303            i
304        );
305    }
306
307    #[test]
308    fn test_encrypt_decrypt() {
309        let params = test_params();
310        for (i, test) in test_cases().iter().enumerate() {
311            roundtrip(test, &params, i);
312        }
313    }
314
315    #[test]
316    #[ignore = "uses production Argon2id parameters (1 GiB, 8 passes); run manually with --ignored"]
317    fn test_encrypt_decrypt_full_params() {
318        let params = production_params();
319        // A single case is enough to validate the production profile without
320        // dominating the test runtime.
321        roundtrip(&test_cases()[3], &params, 3);
322    }
323}