Skip to main content

crypt/
crypt.rs

1use std::{env, fs, process};
2
3use crypto::{Aes256Gcm, Xof, 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 = 24;
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, 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// Returns nonce_seed (32 bytes) || chacha20_blake3_ciphertext
80//
81// chacha20_blake3_nonce = derive_key(nonce_seed, "...", 24)
82// aes_nonce             = derive_key(nonce_seed, "...", 12)
83// argon2_salt           = derive_key(nonce_seed, "...", 32)
84fn encrypt(password: &[u8], plaintext: &[u8]) -> Result<Vec<u8>, String> {
85    let nonce_seed: [u8; NONCE_SEED_LENGTH] = rand::random();
86
87    let chacha20_nonce = derive_key::<CHACHA20_BLAKE3_NONCE_LENGTH>(&nonce_seed, KDF_INFO_CHACHA20_BLAKE3_NONCE);
88    let aes_nonce = derive_key::<AES_NONCE_LENGTH>(&nonce_seed, KDF_INFO_AES_NONCE);
89    let argon2_salt = derive_key::<ARGON2_SALT_LENGTH>(&nonce_seed, KDF_INFO_ARGON2_SALT);
90
91    let root_key = argon2_derive_key(password, argon2_salt.as_slice())?;
92
93    let aes_key = derive_key::<KEY_LENGTH>(root_key.as_slice(), KDF_INFO_AES_KEY);
94    let chacha20_key = derive_key::<KEY_LENGTH>(root_key.as_slice(), KDF_INFO_CHACHA20_BLAKE3_KEY);
95
96    // Encrypt inner layer with AES-256-GCM
97    let aes = Aes256Gcm::new(&aes_key);
98    let mut aes_buf = plaintext.to_vec();
99    let tag = aes.encrypt_in_place(&mut aes_buf, &aes_nonce, &[]);
100    aes_buf.extend_from_slice(&tag);
101
102    // Encrypt outer layer with ChaCha20-BLAKE3
103    let cipher = chacha20_blake3::ChaCha20Blake3::new(*chacha20_key);
104    let outer_ciphertext = cipher.encrypt(&chacha20_nonce, &aes_buf, &[]);
105
106    let mut result = Vec::with_capacity(NONCE_SEED_LENGTH + outer_ciphertext.len());
107    result.extend_from_slice(&nonce_seed);
108    result.extend_from_slice(&outer_ciphertext);
109
110    Ok(result)
111}
112
113fn decrypt(password: &[u8], ciphertext: &[u8]) -> Result<Vec<u8>, String> {
114    if ciphertext.len() < (NONCE_SEED_LENGTH + chacha20_blake3::TAG_SIZE) {
115        return Err("ciphertext is too short".to_string());
116    }
117
118    let nonce_seed = &ciphertext[..NONCE_SEED_LENGTH];
119    let ciphertext = &ciphertext[NONCE_SEED_LENGTH..];
120
121    let chacha20_nonce = derive_key::<CHACHA20_BLAKE3_NONCE_LENGTH>(&nonce_seed, KDF_INFO_CHACHA20_BLAKE3_NONCE);
122    let aes_nonce = derive_key::<AES_NONCE_LENGTH>(&nonce_seed, KDF_INFO_AES_NONCE);
123    let argon2_salt = derive_key::<ARGON2_SALT_LENGTH>(&nonce_seed, KDF_INFO_ARGON2_SALT);
124
125    let root_key = argon2_derive_key(password, argon2_salt.as_slice())?;
126
127    let aes_key = derive_key::<KEY_LENGTH>(root_key.as_slice(), KDF_INFO_AES_KEY);
128    let chacha20_key = derive_key::<KEY_LENGTH>(root_key.as_slice(), KDF_INFO_CHACHA20_BLAKE3_KEY);
129
130    // Decrypt outer layer with ChaCha20-BLAKE3
131    let cipher = chacha20_blake3::ChaCha20Blake3::new(*chacha20_key);
132    let aes_ciphertext = cipher
133        .decrypt(&chacha20_nonce, ciphertext, &[])
134        .map_err(|e| format!("error decrypting data with ChaCha20-BLAKE3: {e:?}"))?;
135
136    // Decrypt inner layer with AES-256-GCM
137    if aes_ciphertext.len() < Aes256Gcm::TAG_SIZE {
138        return Err("ciphertext is too short for AES-256-GCM tag".to_string());
139    }
140
141    let aes = Aes256Gcm::new(&aes_key);
142    let tag_pos = aes_ciphertext.len() - Aes256Gcm::TAG_SIZE;
143    let tag: [u8; 16] = aes_ciphertext[tag_pos..].try_into().unwrap();
144    let mut plaintext_buf = aes_ciphertext[..tag_pos].to_vec();
145    aes.decrypt_in_place(&mut plaintext_buf, &tag, &aes_nonce, &[])
146        .map_err(|_| "error decrypting data with AES-256-GCM: authentication failed".to_string())?;
147
148    Ok(plaintext_buf)
149}
150
151fn derive_key<const N: usize>(root_key: &[u8], info: &str) -> Zeroizing<[u8; N]> {
152    let mut out = Zeroizing::new([0u8; N]);
153
154    let mut shake = Shake256::new();
155    shake.absorb(root_key);
156    shake.absorb(&(root_key.len() as u64).to_le_bytes());
157    shake.absorb(info.as_bytes());
158    shake.absorb(&(info.len() as u64).to_le_bytes());
159    shake.absorb(&(N as u64).to_le_bytes());
160    shake.squeeze(out.as_mut_slice());
161
162    return out;
163}
164
165fn argon2_derive_key(password: &[u8], salt: &[u8]) -> Result<Zeroizing<[u8; KEY_LENGTH]>, String> {
166    let params = argon2::Params::new(ARGON2_MEMORY_KB, ARGON2_ITERATIONS, ARGON2_LANES, Some(KEY_LENGTH))
167        .map_err(|e| format!("error creating argon2 params: {e}"))?;
168    let argon2_instance = argon2::Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params);
169    let mut key = Zeroizing::new([0u8; KEY_LENGTH]);
170    argon2_instance
171        .hash_password_into(password, salt, key.as_mut_slice())
172        .map_err(|e| format!("error deriving key with argon2: {e}"))?;
173    Ok(key)
174}
175
176fn ask_for_password(confirm: bool) -> Result<Vec<u8>, String> {
177    eprint!("Password: ");
178    let password = term::read_password().map_err(|e| format!("error reading password: {e}"))?;
179    eprintln!();
180
181    if password.is_empty() {
182        return Err("password is empty".to_string());
183    }
184
185    if confirm {
186        eprint!("Confirm Password: ");
187        let mut confirmation =
188            term::read_password().map_err(|e| format!("error reading password confirmation: {e}"))?;
189        eprintln!();
190
191        let matches = password == confirmation;
192        confirmation.zeroize();
193
194        if !matches {
195            return Err("passwords don't match".to_string());
196        }
197    }
198
199    Ok(password)
200}
201
202fn print_help_and_exit(exit_code: i32) -> ! {
203    eprintln!("usage: crypt <encrypt|decrypt> <in> <out>");
204    process::exit(exit_code);
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210
211    struct TestCase {
212        password: &'static str,
213        data: &'static str,
214    }
215
216    fn test_cases() -> Vec<TestCase> {
217        vec![
218            TestCase {
219                password: "",
220                data: "",
221            },
222            TestCase {
223                password: "password",
224                data: "",
225            },
226            TestCase {
227                password: "",
228                data: "data",
229            },
230            TestCase {
231                password: "password",
232                data: "data",
233            },
234            TestCase {
235                password: "password",
236                // echo -n 'data' | shasum -a 512, repeated
237                data: "77c7ce9a5d86bb386d443bb96390faa120633158699c8844c30b13ab0bf92760b7e4416aea397db91b4ac0e5dd56b8ef7e4b066162ab1fdc088319ce6defc87677c7ce9a5d86bb386d443bb96390faa120633158699c8844c30b13ab0bf92760b7e4416aea397db91b4ac0e5dd56b8ef7e4b066162ab1fdc088319ce6defc87677c7ce9a5d86bb386d443bb96390faa120633158699c8844c30b13ab0bf92760b7e4416aea397db91b4ac0e5dd56b8ef7e4b066162ab1fdc088319ce6defc876",
238            },
239        ]
240    }
241
242    #[test]
243    fn test_encrypt_decrypt() {
244        for (i, test) in test_cases().iter().enumerate() {
245            let password = test.password.as_bytes();
246            let data = test.data.as_bytes();
247
248            let ciphertext = encrypt(password, data).unwrap_or_else(|e| panic!("error encrypting data [{}]: {}", i, e));
249
250            // Ciphertext must not equal plaintext
251            assert!(
252                ciphertext != data && (data.is_empty() || &ciphertext[..data.len()] != data),
253                "ciphertext == data for {}",
254                i
255            );
256
257            let plaintext =
258                decrypt(password, &ciphertext).unwrap_or_else(|e| panic!("error decrypting data [{}]: {}", i, e));
259
260            // Wrong password must fail
261            let mut wrong_password = test.password.to_string();
262            wrong_password.push('1');
263            let ciphertext2 = ciphertext.clone();
264            let wrong_result = decrypt(wrong_password.as_bytes(), &ciphertext2);
265            assert!(
266                wrong_result.is_err(),
267                "expected error when using invalid password decrypting data for [{}]",
268                i
269            );
270
271            assert_eq!(
272                plaintext,
273                data,
274                "data ({}) != decrypted plaintext ({}) for {}",
275                test.data,
276                String::from_utf8_lossy(&plaintext),
277                i
278            );
279        }
280    }
281}