Skip to main content

safe_migrate/db/
cache_file.rs

1use anyhow::{Context, Result, anyhow, bail};
2use chacha20poly1305::{
3    XChaCha20Poly1305, XNonce,
4    aead::{Aead, Generate, KeyInit},
5};
6use std::fs::File;
7use std::io::Read;
8use std::path::Path;
9
10pub const CACHE_KEY_ENV: &str = "SAFE_MIGRATE_CACHE_KEY";
11pub const MAX_CACHE_FILE_BYTES: u64 = 64 * 1024 * 1024;
12pub const MAX_CACHE_DECODE_BYTES: usize = 256 * 1024 * 1024;
13const ENCRYPTED_CACHE_MAGIC: &[u8] = b"SMENC001";
14const NONCE_LENGTH: usize = 24;
15
16pub fn read_cache_bytes(cache_path: &Path) -> Result<Vec<u8>> {
17    read_cache_bytes_with_limit(cache_path, MAX_CACHE_FILE_BYTES)
18}
19
20fn read_cache_bytes_with_limit(cache_path: &Path, max_bytes: u64) -> Result<Vec<u8>> {
21    let file = File::open(cache_path)
22        .with_context(|| format!("Failed to read cache file: {}", cache_path.display()))?;
23    let initial_size = file.metadata().map(|metadata| metadata.len()).unwrap_or(0);
24    let initial_capacity = initial_size.min(max_bytes) as usize;
25    let mut bytes = Vec::with_capacity(initial_capacity);
26    file.take(max_bytes.saturating_add(1))
27        .read_to_end(&mut bytes)
28        .with_context(|| format!("Failed to read cache file: {}", cache_path.display()))?;
29    if bytes.len() as u64 > max_bytes {
30        bail!(
31            "Cache file '{}' exceeds the {} MiB encoded-size limit",
32            cache_path.display(),
33            max_bytes / (1024 * 1024)
34        );
35    }
36    Ok(bytes)
37}
38
39/// Identifies the safe-migrate encryption envelope without attempting to
40/// decrypt it. This supports safe metadata inspection without exposing key
41/// material or payload contents.
42pub fn is_encrypted_cache_bytes(cache_bytes: &[u8]) -> bool {
43    cache_bytes.starts_with(ENCRYPTED_CACHE_MAGIC)
44}
45
46/// Encrypts an encoded cache when cache encryption is enabled. The on-disk
47/// envelope includes only a format marker and random nonce; the authenticated
48/// ciphertext contains all cache metadata.
49pub fn protect_cache_bytes(cache_bytes: Vec<u8>, encryption_enabled: bool) -> Result<Vec<u8>> {
50    if !encryption_enabled {
51        return Ok(cache_bytes);
52    }
53
54    let cipher = cipher_from_environment()?;
55    let nonce = XNonce::generate();
56    let ciphertext = cipher
57        .encrypt(&nonce, cache_bytes.as_ref())
58        .map_err(|_| anyhow!("Failed to encrypt cache payload"))?;
59
60    let mut envelope =
61        Vec::with_capacity(ENCRYPTED_CACHE_MAGIC.len() + NONCE_LENGTH + ciphertext.len());
62    envelope.extend_from_slice(ENCRYPTED_CACHE_MAGIC);
63    envelope.extend_from_slice(&nonce);
64    envelope.extend_from_slice(&ciphertext);
65    Ok(envelope)
66}
67
68/// Returns plaintext encoded cache bytes. Encrypted files require both an
69/// enabled configuration and the environment-only key; authentication failures
70/// intentionally do not distinguish a wrong key from modified ciphertext.
71pub fn unprotect_cache_bytes(cache_bytes: Vec<u8>, encryption_enabled: bool) -> Result<Vec<u8>> {
72    if !is_encrypted_cache_bytes(&cache_bytes) {
73        if encryption_enabled {
74            bail!(
75                "Cache file is not encrypted, but cache_encryption = true. Run `safe-migrate sync` to create an encrypted cache."
76            );
77        }
78        return Ok(cache_bytes);
79    }
80
81    if !encryption_enabled {
82        bail!(
83            "Cache file is encrypted. Set cache_encryption = true and provide {} to read it.",
84            CACHE_KEY_ENV
85        );
86    }
87
88    let nonce_end = ENCRYPTED_CACHE_MAGIC.len() + NONCE_LENGTH;
89    if cache_bytes.len() <= nonce_end {
90        bail!("Encrypted cache file is truncated");
91    }
92
93    let cipher = cipher_from_environment()?;
94    let nonce = XNonce::try_from(&cache_bytes[ENCRYPTED_CACHE_MAGIC.len()..nonce_end])
95        .map_err(|_| anyhow!("Encrypted cache has an invalid nonce"))?;
96    cipher
97        .decrypt(&nonce, &cache_bytes[nonce_end..])
98        .map_err(|_| anyhow!("Failed to decrypt cache: key is incorrect or the file was modified"))
99}
100
101fn cipher_from_environment() -> Result<XChaCha20Poly1305> {
102    let raw_key = std::env::var(CACHE_KEY_ENV).with_context(|| {
103        format!(
104            "{} must contain a 64-character hexadecimal key when cache_encryption is enabled",
105            CACHE_KEY_ENV
106        )
107    })?;
108    let key = decode_hex_key(raw_key.trim())?;
109    XChaCha20Poly1305::new_from_slice(&key)
110        .map_err(|_| anyhow!("{} must contain exactly 32 key bytes", CACHE_KEY_ENV))
111}
112
113fn decode_hex_key(input: &str) -> Result<[u8; 32]> {
114    if input.len() != 64 {
115        bail!(
116            "{} must be exactly 64 hexadecimal characters",
117            CACHE_KEY_ENV
118        );
119    }
120
121    let mut key = [0u8; 32];
122    for (index, byte) in key.iter_mut().enumerate() {
123        let offset = index * 2;
124        let high = hex_nibble(input.as_bytes()[offset])?;
125        let low = hex_nibble(input.as_bytes()[offset + 1])?;
126        *byte = (high << 4) | low;
127    }
128    Ok(key)
129}
130
131fn hex_nibble(byte: u8) -> Result<u8> {
132    match byte {
133        b'0'..=b'9' => Ok(byte - b'0'),
134        b'a'..=b'f' => Ok(byte - b'a' + 10),
135        b'A'..=b'F' => Ok(byte - b'A' + 10),
136        _ => bail!("{} must contain only hexadecimal characters", CACHE_KEY_ENV),
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143    use crate::test_support::EnvironmentValueGuard;
144    use std::io::Write;
145    use tempfile::NamedTempFile;
146
147    fn with_test_key(test: impl FnOnce()) {
148        let _guard = EnvironmentValueGuard::set(CACHE_KEY_ENV, &"42".repeat(32));
149        test();
150    }
151
152    #[test]
153    fn decode_hex_key_requires_exact_hex_key_material() {
154        assert!(decode_hex_key(&"a1".repeat(32)).is_ok());
155        assert!(decode_hex_key("not-a-key").is_err());
156        assert!(decode_hex_key(&"zz".repeat(32)).is_err());
157    }
158
159    #[test]
160    fn cache_file_reader_rejects_data_beyond_its_limit() {
161        let mut cache = NamedTempFile::new().unwrap();
162        cache.write_all(b"12345").unwrap();
163
164        let error = read_cache_bytes_with_limit(cache.path(), 4).unwrap_err();
165        assert!(error.to_string().contains("encoded-size limit"));
166    }
167
168    #[test]
169    fn encrypted_cache_round_trip_authenticates_the_payload() {
170        with_test_key(|| {
171            let plaintext = b"cache payload".to_vec();
172            let encrypted = protect_cache_bytes(plaintext.clone(), true).unwrap();
173            assert_ne!(encrypted, plaintext);
174            assert_eq!(
175                unprotect_cache_bytes(encrypted.clone(), true).unwrap(),
176                plaintext
177            );
178
179            let mut modified = encrypted;
180            let last = modified.len() - 1;
181            modified[last] ^= 1;
182            assert!(unprotect_cache_bytes(modified, true).is_err());
183        });
184    }
185
186    #[test]
187    fn encryption_required_rejects_plaintext_cache_bytes() {
188        let error = unprotect_cache_bytes(b"plaintext cache".to_vec(), true)
189            .expect_err("encryption-enabled configuration must reject plaintext caches");
190        assert!(error.to_string().contains("not encrypted"));
191    }
192}