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; const 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
79fn 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
88fn encrypt(password: &[u8], plaintext: &[u8]) -> Result<Vec<u8>, String> {
94 encrypt_with_params(password, plaintext, &production_params())
95}
96
97fn 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 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 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
131fn 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 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 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 data: "77c7ce9a5d86bb386d443bb96390faa120633158699c8844c30b13ab0bf92760b7e4416aea397db91b4ac0e5dd56b8ef7e4b066162ab1fdc088319ce6defc87677c7ce9a5d86bb386d443bb96390faa120633158699c8844c30b13ab0bf92760b7e4416aea397db91b4ac0e5dd56b8ef7e4b066162ab1fdc088319ce6defc87677c7ce9a5d86bb386d443bb96390faa120633158699c8844c30b13ab0bf92760b7e4416aea397db91b4ac0e5dd56b8ef7e4b066162ab1fdc088319ce6defc876",
254 },
255 ]
256 }
257
258 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 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 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, ¶ms, 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 roundtrip(&test_cases()[3], ¶ms, 3);
322 }
323}