1use std::{
18 fs,
19 io::{Read, Write},
20 path::Path,
21};
22
23use anyhow::{bail, Context, Result};
24use argon2::{Algorithm, Argon2, Params, Version};
25use chacha20poly1305::{
26 aead::{
27 generic_array::GenericArray,
28 stream::{DecryptorBE32, EncryptorBE32},
29 },
30 KeyInit, XChaCha20Poly1305,
31};
32use rand::RngCore;
33use serde_json::{json, Value};
34use sha2::{Digest, Sha256};
35
36const MAGIC: &[u8; 8] = b"TCENC001";
37const NONCE_LEN: usize = 19;
38const CHUNK: usize = 64 * 1024;
39const TAG: usize = 16;
40const M_COST: u32 = 64 * 1024;
42const T_COST: u32 = 3;
43const P_COST: u32 = 1;
44
45pub struct BackupKey {
47 key: [u8; 32],
48 salt: [u8; 16],
49 m_cost: u32,
50 t_cost: u32,
51 p_cost: u32,
52}
53
54impl std::fmt::Debug for BackupKey {
56 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
57 f.debug_struct("BackupKey")
58 .field("key", &"<redacted>")
59 .field("m_cost", &self.m_cost)
60 .finish()
61 }
62}
63
64impl BackupKey {
65 pub fn new(passphrase: &str) -> Result<BackupKey> {
67 let mut salt = [0u8; 16];
68 rand::rngs::OsRng.fill_bytes(&mut salt);
69 Self::derive(passphrase, salt, M_COST, T_COST, P_COST)
70 }
71
72 #[cfg(test)]
75 pub(crate) fn for_tests(passphrase: &str) -> BackupKey {
76 Self::derive(passphrase, [7; 16], 64, 1, 1).expect("test key")
77 }
78
79 fn derive(
80 passphrase: &str,
81 salt: [u8; 16],
82 m_cost: u32,
83 t_cost: u32,
84 p_cost: u32,
85 ) -> Result<BackupKey> {
86 if passphrase.chars().count() < 12 {
87 bail!("the backup passphrase must have at least 12 characters");
88 }
89 let params = Params::new(m_cost, t_cost, p_cost, Some(32))
90 .map_err(|e| anyhow::anyhow!("invalid key derivation parameters: {e}"))?;
91 let mut key = [0u8; 32];
92 Argon2::new(Algorithm::Argon2id, Version::V0x13, params)
93 .hash_password_into(passphrase.as_bytes(), &salt, &mut key)
94 .map_err(|e| anyhow::anyhow!("key derivation failed: {e}"))?;
95 Ok(BackupKey {
96 key,
97 salt,
98 m_cost,
99 t_cost,
100 p_cost,
101 })
102 }
103
104 fn check(&self) -> String {
107 let mut h = Sha256::new();
108 h.update(b"teamcraft-backup-key-check");
109 h.update(self.key);
110 hex::encode(h.finalize())
111 }
112
113 pub fn manifest_entry(&self) -> Value {
115 json!({
116 "cipher": "xchacha20poly1305-stream-be32",
117 "kdf": "argon2id",
118 "salt": hex::encode(self.salt),
119 "m_cost": self.m_cost,
120 "t_cost": self.t_cost,
121 "p_cost": self.p_cost,
122 "key_check": self.check(),
123 })
124 }
125
126 pub fn from_manifest(passphrase: &str, entry: &Value) -> Result<BackupKey> {
129 let field = |name: &str| entry.get(name).context(format!("encryption: no '{name}'"));
130 if field("cipher")?.as_str() != Some("xchacha20poly1305-stream-be32")
131 || field("kdf")?.as_str() != Some("argon2id")
132 {
133 bail!("the backup is encrypted with an unknown method");
134 }
135 let salt: [u8; 16] = hex::decode(field("salt")?.as_str().unwrap_or(""))
136 .ok()
137 .and_then(|s| s.try_into().ok())
138 .context("encryption: invalid 'salt'")?;
139 let cost = |name: &str| -> Result<u32> {
140 field(name)?
141 .as_u64()
142 .and_then(|v| u32::try_from(v).ok())
143 .filter(|v| *v > 0 && *v <= 4 * 1024 * 1024)
144 .with_context(|| format!("encryption: invalid '{name}'"))
145 };
146 let key = Self::derive(
147 passphrase,
148 salt,
149 cost("m_cost")?,
150 cost("t_cost")?,
151 cost("p_cost")?,
152 )?;
153 if Some(key.check().as_str()) != field("key_check")?.as_str() {
154 bail!("wrong backup passphrase");
155 }
156 Ok(key)
157 }
158
159 fn cipher(&self) -> XChaCha20Poly1305 {
160 XChaCha20Poly1305::new(GenericArray::from_slice(&self.key))
161 }
162}
163
164fn fill(reader: &mut impl Read, buf: &mut [u8]) -> std::io::Result<usize> {
166 let mut n = 0;
167 while n < buf.len() {
168 match reader.read(&mut buf[n..])? {
169 0 => break,
170 k => n += k,
171 }
172 }
173 Ok(n)
174}
175
176pub fn encrypt(
179 key: &BackupKey,
180 plain: &mut impl Read,
181 out: &mut impl Write,
182) -> Result<(String, u64)> {
183 let mut nonce = [0u8; NONCE_LEN];
184 rand::rngs::OsRng.fill_bytes(&mut nonce);
185 out.write_all(MAGIC)?;
186 out.write_all(&nonce)?;
187 let mut enc = EncryptorBE32::from_aead(key.cipher(), GenericArray::from_slice(&nonce));
188 let mut hasher = Sha256::new();
189 let mut total = 0u64;
190 let mut cur = vec![0u8; CHUNK];
191 let mut next = vec![0u8; CHUNK];
192 let mut cur_len = fill(plain, &mut cur)?;
193 loop {
194 hasher.update(&cur[..cur_len]);
195 total += cur_len as u64;
196 let next_len = if cur_len == CHUNK {
198 fill(plain, &mut next)?
199 } else {
200 0
201 };
202 if next_len == 0 {
203 let sealed = enc
204 .encrypt_last(&cur[..cur_len])
205 .map_err(|_| anyhow::anyhow!("encryption failed"))?;
206 out.write_all(&sealed)?;
207 break;
208 }
209 let sealed = enc
210 .encrypt_next(&cur[..cur_len])
211 .map_err(|_| anyhow::anyhow!("encryption failed"))?;
212 out.write_all(&sealed)?;
213 std::mem::swap(&mut cur, &mut next);
214 cur_len = next_len;
215 }
216 out.flush()?;
217 Ok((hex::encode(hasher.finalize()), total))
218}
219
220pub fn decrypt(key: &BackupKey, sealed: &mut impl Read, out: &mut impl Write) -> Result<String> {
223 let mut header = [0u8; 8 + NONCE_LEN];
224 if fill(sealed, &mut header)? != header.len() || &header[..8] != MAGIC {
225 bail!("not an encrypted TeamCraft backup file");
226 }
227 let mut dec = DecryptorBE32::from_aead(key.cipher(), GenericArray::from_slice(&header[8..]));
228 let mut hasher = Sha256::new();
229 let mut cur = vec![0u8; CHUNK + TAG];
230 let mut next = vec![0u8; CHUNK + TAG];
231 let mut cur_len = fill(sealed, &mut cur)?;
232 let damaged = || anyhow::anyhow!("the file is damaged or was changed (authentication failed)");
233 loop {
234 if cur_len < TAG {
235 return Err(damaged());
236 }
237 let next_len = if cur_len == CHUNK + TAG {
238 fill(sealed, &mut next)?
239 } else {
240 0
241 };
242 if next_len == 0 {
243 let plain = dec.decrypt_last(&cur[..cur_len]).map_err(|_| damaged())?;
244 hasher.update(&plain);
245 out.write_all(&plain)?;
246 break;
247 }
248 let plain = dec.decrypt_next(&cur[..cur_len]).map_err(|_| damaged())?;
249 hasher.update(&plain);
250 out.write_all(&plain)?;
251 std::mem::swap(&mut cur, &mut next);
252 cur_len = next_len;
253 }
254 out.flush()?;
255 Ok(hex::encode(hasher.finalize()))
256}
257
258pub fn encrypt_file(key: &BackupKey, source: &Path, target: &Path) -> Result<(String, u64)> {
260 let mut input = std::io::BufReader::new(
261 fs::File::open(source).with_context(|| format!("cannot read '{}'", source.display()))?,
262 );
263 let mut output = std::io::BufWriter::new(
264 fs::File::create_new(target)
265 .with_context(|| format!("cannot create '{}'", target.display()))?,
266 );
267 let result = encrypt(key, &mut input, &mut output)
268 .with_context(|| format!("cannot encrypt '{}'", source.display()))?;
269 output
270 .into_inner()
271 .map_err(|e| anyhow::anyhow!("cannot write '{}': {e}", target.display()))?
272 .sync_all()?;
273 Ok(result)
274}
275
276pub fn decrypt_file(key: &BackupKey, source: &Path, out: &mut impl Write) -> Result<String> {
278 let mut input = std::io::BufReader::new(
279 fs::File::open(source).with_context(|| format!("cannot read '{}'", source.display()))?,
280 );
281 decrypt(key, &mut input, out).with_context(|| format!("'{}'", source.display()))
282}
283
284#[cfg(test)]
285mod tests {
286 use super::*;
287
288 fn quick_key(pass: &str) -> BackupKey {
289 BackupKey::derive(pass, [7; 16], 64, 1, 1).unwrap()
291 }
292
293 fn roundtrip(len: usize) {
294 let key = quick_key("correct horse battery");
295 let plain: Vec<u8> = (0..len).map(|i| (i % 251) as u8).collect();
296 let mut sealed = Vec::new();
297 let (sha, n) = encrypt(&key, &mut plain.as_slice(), &mut sealed).unwrap();
298 assert_eq!(n, len as u64);
299 assert_eq!(sha, hex::encode(Sha256::digest(&plain)));
300 if len > 0 {
301 assert_ne!(&sealed[27..27 + len.min(16)], &plain[..len.min(16)]);
302 }
303 let mut back = Vec::new();
304 assert_eq!(
305 decrypt(&key, &mut sealed.as_slice(), &mut back).unwrap(),
306 sha
307 );
308 assert_eq!(back, plain, "{len} bytes");
309 }
310
311 #[test]
312 fn roundtrips_at_chunk_boundaries() {
313 for len in [0, 1, CHUNK - 1, CHUNK, CHUNK + 1, 3 * CHUNK, 3 * CHUNK + 7] {
314 roundtrip(len);
315 }
316 }
317
318 #[test]
319 fn tampering_truncation_and_wrong_keys_fail() {
320 let key = quick_key("correct horse battery");
321 let plain = vec![42u8; 2 * CHUNK + 10];
322 let mut sealed = Vec::new();
323 encrypt(&key, &mut plain.as_slice(), &mut sealed).unwrap();
324 let mut sink = Vec::new();
325 let mut flipped = sealed.clone();
326 flipped[100] ^= 1;
327 assert!(decrypt(&key, &mut flipped.as_slice(), &mut sink).is_err());
328 let cut = &sealed[..27 + CHUNK + TAG];
330 assert!(decrypt(&key, &mut &cut[..], &mut Vec::new()).is_err());
331 let mut longer = sealed.clone();
332 longer.extend_from_slice(&[0; 20]);
333 assert!(decrypt(&key, &mut longer.as_slice(), &mut Vec::new()).is_err());
334 let other = quick_key("another passphrase!");
335 assert!(decrypt(&other, &mut sealed.as_slice(), &mut Vec::new()).is_err());
336 assert!(decrypt(&key, &mut &b"plain dump"[..], &mut Vec::new()).is_err());
337 }
338
339 #[test]
340 fn manifest_entry_checks_the_passphrase() {
341 let key = quick_key("correct horse battery");
342 let entry = key.manifest_entry();
343 assert_eq!(entry["kdf"], "argon2id");
344 assert!(BackupKey::from_manifest("correct horse battery", &entry).is_ok());
345 let wrong = BackupKey::from_manifest("wrong horse battery!", &entry);
346 assert!(wrong
347 .err()
348 .unwrap()
349 .to_string()
350 .contains("wrong backup passphrase"));
351 assert!(BackupKey::new("short").is_err(), "at least 12 characters");
352 let mut tampered = entry.clone();
353 tampered["m_cost"] = json!(0);
354 assert!(BackupKey::from_manifest("correct horse battery", &tampered).is_err());
355 }
356}