safe_migrate/db/
cache_file.rs1use 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
39pub fn is_encrypted_cache_bytes(cache_bytes: &[u8]) -> bool {
43 cache_bytes.starts_with(ENCRYPTED_CACHE_MAGIC)
44}
45
46pub 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
68pub(crate) fn validate_cache_encryption_configuration(encryption_enabled: bool) -> Result<()> {
69 if encryption_enabled {
70 cipher_from_environment()?;
71 }
72 Ok(())
73}
74
75pub fn unprotect_cache_bytes(cache_bytes: Vec<u8>, encryption_enabled: bool) -> Result<Vec<u8>> {
79 if !is_encrypted_cache_bytes(&cache_bytes) {
80 if encryption_enabled {
81 bail!(
82 "Cache file is not encrypted, but cache_encryption = true. Run `safe-migrate sync` to create an encrypted cache."
83 );
84 }
85 return Ok(cache_bytes);
86 }
87
88 if !encryption_enabled {
89 bail!(
90 "Cache file is encrypted. Set cache_encryption = true and provide {} to read it.",
91 CACHE_KEY_ENV
92 );
93 }
94
95 let nonce_end = ENCRYPTED_CACHE_MAGIC.len() + NONCE_LENGTH;
96 if cache_bytes.len() <= nonce_end {
97 bail!("Encrypted cache file is truncated");
98 }
99
100 let cipher = cipher_from_environment()?;
101 let nonce = XNonce::try_from(&cache_bytes[ENCRYPTED_CACHE_MAGIC.len()..nonce_end])
102 .map_err(|_| anyhow!("Encrypted cache has an invalid nonce"))?;
103 cipher
104 .decrypt(&nonce, &cache_bytes[nonce_end..])
105 .map_err(|_| anyhow!("Failed to decrypt cache: key is incorrect or the file was modified"))
106}
107
108fn cipher_from_environment() -> Result<XChaCha20Poly1305> {
109 let raw_key = std::env::var(CACHE_KEY_ENV).with_context(|| {
110 format!(
111 "{} must contain a 64-character hexadecimal key when cache_encryption is enabled",
112 CACHE_KEY_ENV
113 )
114 })?;
115 let key = decode_hex_key(raw_key.trim())?;
116 XChaCha20Poly1305::new_from_slice(&key)
117 .map_err(|_| anyhow!("{} must contain exactly 32 key bytes", CACHE_KEY_ENV))
118}
119
120fn decode_hex_key(input: &str) -> Result<[u8; 32]> {
121 if input.len() != 64 {
122 bail!(
123 "{} must be exactly 64 hexadecimal characters",
124 CACHE_KEY_ENV
125 );
126 }
127
128 let mut key = [0u8; 32];
129 for (index, byte) in key.iter_mut().enumerate() {
130 let offset = index * 2;
131 let high = hex_nibble(input.as_bytes()[offset])?;
132 let low = hex_nibble(input.as_bytes()[offset + 1])?;
133 *byte = (high << 4) | low;
134 }
135 Ok(key)
136}
137
138fn hex_nibble(byte: u8) -> Result<u8> {
139 match byte {
140 b'0'..=b'9' => Ok(byte - b'0'),
141 b'a'..=b'f' => Ok(byte - b'a' + 10),
142 b'A'..=b'F' => Ok(byte - b'A' + 10),
143 _ => bail!("{} must contain only hexadecimal characters", CACHE_KEY_ENV),
144 }
145}
146
147#[cfg(test)]
148mod tests {
149 use super::*;
150 use crate::test_support::EnvironmentValueGuard;
151 use std::io::Write;
152 use tempfile::NamedTempFile;
153
154 fn with_test_key(test: impl FnOnce()) {
155 let _guard = EnvironmentValueGuard::set(CACHE_KEY_ENV, &"42".repeat(32));
156 test();
157 }
158
159 #[test]
160 fn decode_hex_key_requires_exact_hex_key_material() {
161 assert!(decode_hex_key(&"a1".repeat(32)).is_ok());
162 assert!(decode_hex_key("not-a-key").is_err());
163 assert!(decode_hex_key(&"zz".repeat(32)).is_err());
164 }
165
166 #[test]
167 fn cache_file_reader_rejects_data_beyond_its_limit() {
168 let mut cache = NamedTempFile::new().unwrap();
169 cache.write_all(b"12345").unwrap();
170
171 let error = read_cache_bytes_with_limit(cache.path(), 4).unwrap_err();
172 assert!(error.to_string().contains("encoded-size limit"));
173 }
174
175 #[test]
176 fn encrypted_cache_round_trip_authenticates_the_payload() {
177 with_test_key(|| {
178 let plaintext = b"cache payload".to_vec();
179 let encrypted = protect_cache_bytes(plaintext.clone(), true).unwrap();
180 assert_ne!(encrypted, plaintext);
181 assert_eq!(
182 unprotect_cache_bytes(encrypted.clone(), true).unwrap(),
183 plaintext
184 );
185
186 let mut modified = encrypted;
187 let last = modified.len() - 1;
188 modified[last] ^= 1;
189 assert!(unprotect_cache_bytes(modified, true).is_err());
190 });
191 }
192
193 #[test]
194 fn encryption_required_rejects_plaintext_cache_bytes() {
195 let error = unprotect_cache_bytes(b"plaintext cache".to_vec(), true)
196 .expect_err("encryption-enabled configuration must reject plaintext caches");
197 assert!(error.to_string().contains("not encrypted"));
198 }
199
200 #[test]
201 fn encryption_configuration_is_validated_without_payload_processing() {
202 let _guard = EnvironmentValueGuard::set(CACHE_KEY_ENV, "not-a-key");
203 let error = validate_cache_encryption_configuration(true).unwrap_err();
204 assert!(error.to_string().contains("64 hexadecimal characters"));
205 assert!(validate_cache_encryption_configuration(false).is_ok());
206 }
207}