Skip to main content

teamcraft/
backupcrypt.rs

1//! Encryption of backup files (`teamcraft backup --encrypt`).
2//!
3//! The key is derived from a passphrase with Argon2id (salt and cost in the
4//! manifest). Every file – the database dump and each vault object – is
5//! encrypted on its own with XChaCha20-Poly1305 in the STREAM construction
6//! (64 KiB chunks, each authenticated, the last one marked), so a file is
7//! never held in memory, truncation and reordering are detected, and a
8//! wrong passphrase or tampering fails before anything is restored:
9//!
10//! ```text
11//! "TCENC001" | 19-byte random nonce | chunk₁ … chunkₙ (≤ 64 KiB + 16-byte tag each)
12//! ```
13//!
14//! The SHA-256 of the plain content is computed on the way in and out, so
15//! the manifest and the object names keep naming the plain content.
16
17use 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;
40/// Argon2id cost of new backups: 64 MiB, 3 passes, 1 lane.
41const M_COST: u32 = 64 * 1024;
42const T_COST: u32 = 3;
43const P_COST: u32 = 1;
44
45/// A derived backup key with the parameters it came from.
46pub struct BackupKey {
47    key: [u8; 32],
48    salt: [u8; 16],
49    m_cost: u32,
50    t_cost: u32,
51    p_cost: u32,
52}
53
54/// Never shows the key.
55impl 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    /// A fresh key for a new backup (random salt).
66    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    /// A key with cheap derivation parameters, for tests that need a key but
73    /// do not test the cost (the real cost takes seconds in a debug build).
74    #[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    /// A value that tells a wrong passphrase apart from a damaged file
105    /// (the key itself is 256 random-looking bits, so this reveals nothing).
106    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    /// The `encryption` object of the manifest.
114    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    /// Derives the key of an existing backup from its manifest entry and
127    /// checks the passphrase.
128    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
164/// Reads until `buf` is full or the input ends; returns the bytes read.
165fn 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
176/// Encrypts everything `plain` yields into `out`; returns the SHA-256 of the
177/// plain content and its length.
178pub 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        // look ahead: the chunk is the last one when nothing follows
197        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
220/// Decrypts `sealed` into `out` (any authentication failure, truncation or
221/// extra data is an error); returns the SHA-256 of the plain content.
222pub 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
258/// Encrypts the file `source` into the new file `target`.
259pub 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
276/// Decrypts the file `source` into `out`; returns the plain SHA-256.
277pub 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        // cheap parameters for the tests
290        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        // a whole chunk cut off: the new last chunk was not sealed as last
329        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}