amaters_cluster/
encryption.rs1use aes_gcm::aead::{Aead, KeyInit};
15use aes_gcm::{Aes256Gcm, Key, Nonce};
16use hkdf::Hkdf;
17use hmac::{Hmac, Mac};
18use sha2::Sha256;
19
20use crate::error::{RaftError, RaftResult};
24
25type HmacSha256 = Hmac<Sha256>;
26
27pub struct LogEncryptionKey {
33 key_bytes: [u8; 32],
34}
35
36impl LogEncryptionKey {
37 pub fn new(key_bytes: [u8; 32]) -> Self {
39 Self { key_bytes }
40 }
41
42 pub fn from_slice(bytes: &[u8]) -> RaftResult<Self> {
47 let key_bytes: [u8; 32] = bytes.try_into().map_err(|_| RaftError::StorageError {
48 message: format!(
49 "LogEncryptionKey requires exactly 32 bytes, got {}",
50 bytes.len()
51 ),
52 })?;
53 Ok(Self { key_bytes })
54 }
55
56 pub fn random() -> Self {
62 use std::collections::hash_map::RandomState;
63 use std::hash::{BuildHasher, Hasher};
64 use std::time::{SystemTime, UNIX_EPOCH};
65
66 let ts_nanos: u128 = SystemTime::now()
67 .duration_since(UNIX_EPOCH)
68 .map(|d| d.as_nanos())
69 .unwrap_or(0u128);
70
71 let rs1 = RandomState::new();
73 let rs2 = RandomState::new();
74 let rs3 = RandomState::new();
75 let rs4 = RandomState::new();
76
77 let h1: u64 = {
78 let mut h = rs1.build_hasher();
79 h.write_u128(ts_nanos);
80 h.finish()
81 };
82 let h2: u64 = {
83 let mut h = rs2.build_hasher();
84 h.write_u128(ts_nanos ^ 0xcafe_babe_dead_beef_1234_5678_abcd_ef01_u128);
86 h.finish()
87 };
88 let h3: u64 = {
89 let mut h = rs3.build_hasher();
90 h.write_u64(h1);
91 h.write_u64(h2);
92 h.finish()
93 };
94 let h4: u64 = {
95 let mut h = rs4.build_hasher();
96 h.write_u64(h2 ^ h3);
97 h.write_u128(ts_nanos.wrapping_add(0x9e37_79b9_7f4a_7c15_f39c_c060_5c0e_d609_u128));
98 h.finish()
99 };
100
101 let mut ikm = [0u8; 32];
103 ikm[0..8].copy_from_slice(&h1.to_le_bytes());
104 ikm[8..16].copy_from_slice(&h2.to_le_bytes());
105 ikm[16..24].copy_from_slice(&h3.to_le_bytes());
106 ikm[24..32].copy_from_slice(&h4.to_le_bytes());
107
108 let salt = b"amaters-log-encryption-key-v1";
109 let hk = Hkdf::<Sha256>::new(Some(salt), &ikm);
110 let mut key_bytes = [0u8; 32];
111 hk.expand(b"master-key", &mut key_bytes)
113 .expect("HKDF expand for 32 bytes cannot fail");
114
115 Self { key_bytes }
116 }
117}
118
119#[derive(Debug, Clone)]
125pub struct EncryptedPayload {
126 pub ciphertext: Vec<u8>,
128 pub nonce: [u8; 12],
130}
131
132pub struct EntryEncryptor {
142 master_key: LogEncryptionKey,
143}
144
145impl EntryEncryptor {
146 pub fn new(key: LogEncryptionKey) -> Self {
148 Self { master_key: key }
149 }
150
151 fn derive_key_and_nonce(&self, entry_index: u64) -> RaftResult<([u8; 32], [u8; 12])> {
153 let hk = Hkdf::<Sha256>::new(None, &self.master_key.key_bytes);
154 let mut derived = [0u8; 44]; hk.expand(&entry_index.to_le_bytes(), &mut derived)
156 .map_err(|e| RaftError::StorageError {
157 message: format!("HKDF expand failed for entry {entry_index}: {e}"),
158 })?;
159
160 let mut key = [0u8; 32];
161 let mut nonce = [0u8; 12];
162 key.copy_from_slice(&derived[..32]);
163 nonce.copy_from_slice(&derived[32..44]);
164 Ok((key, nonce))
165 }
166
167 pub fn encrypt(&self, entry_index: u64, plaintext: &[u8]) -> RaftResult<EncryptedPayload> {
175 let (key_bytes, nonce_bytes) = self.derive_key_and_nonce(entry_index)?;
176
177 let key = Key::<Aes256Gcm>::from(key_bytes);
178 let cipher = Aes256Gcm::new(&key);
179 let nonce = Nonce::from(nonce_bytes);
180
181 let ciphertext =
182 cipher
183 .encrypt(&nonce, plaintext)
184 .map_err(|e| RaftError::StorageError {
185 message: format!("AES-256-GCM encryption failed for entry {entry_index}: {e}"),
186 })?;
187
188 Ok(EncryptedPayload {
189 ciphertext,
190 nonce: nonce_bytes,
191 })
192 }
193
194 pub fn decrypt(&self, entry_index: u64, payload: &EncryptedPayload) -> RaftResult<Vec<u8>> {
203 let (key_bytes, _derived_nonce) = self.derive_key_and_nonce(entry_index)?;
204
205 let key = Key::<Aes256Gcm>::from(key_bytes);
206 let cipher = Aes256Gcm::new(&key);
207 let nonce = Nonce::from(payload.nonce);
208
209 cipher
210 .decrypt(&nonce, payload.ciphertext.as_ref())
211 .map_err(|e| RaftError::StorageError {
212 message: format!("AES-256-GCM decryption failed for entry {entry_index}: {e}"),
213 })
214 }
215}
216
217pub struct LogIntegrityVerifier {
226 key: [u8; 32],
227}
228
229impl LogIntegrityVerifier {
230 pub fn new(key: [u8; 32]) -> Self {
232 Self { key }
233 }
234
235 pub fn compute(&self, entry_index: u64, payload: &EncryptedPayload) -> [u8; 32] {
237 let mut mac = <HmacSha256 as KeyInit>::new_from_slice(&self.key)
238 .expect("HMAC-SHA256 accepts any key size including 32 bytes");
239 mac.update(&entry_index.to_le_bytes());
240 mac.update(&payload.nonce);
241 mac.update(&payload.ciphertext);
242
243 let result = mac.finalize().into_bytes();
244 let mut tag = [0u8; 32];
245 tag.copy_from_slice(&result);
246 tag
247 }
248
249 pub fn verify(
254 &self,
255 entry_index: u64,
256 payload: &EncryptedPayload,
257 tag: &[u8; 32],
258 ) -> RaftResult<()> {
259 let mut mac = <HmacSha256 as KeyInit>::new_from_slice(&self.key)
260 .expect("HMAC-SHA256 accepts any key size including 32 bytes");
261 mac.update(&entry_index.to_le_bytes());
262 mac.update(&payload.nonce);
263 mac.update(&payload.ciphertext);
264
265 mac.verify_slice(tag).map_err(|_| RaftError::StorageError {
267 message: "HMAC-SHA256 integrity verification failed: tag mismatch".to_string(),
268 })
269 }
270}
271
272#[cfg(test)]
277mod tests {
278 use super::*;
279
280 #[test]
281 fn test_encrypt_decrypt_roundtrip() {
282 let key = LogEncryptionKey::random();
283 let encryptor = EntryEncryptor::new(key);
284 let plaintext = b"Hello, Raft log entry!";
285
286 let payload = encryptor
287 .encrypt(42, plaintext)
288 .expect("encrypt should succeed");
289 let decrypted = encryptor
290 .decrypt(42, &payload)
291 .expect("decrypt should succeed");
292
293 assert_eq!(decrypted.as_slice(), plaintext.as_ref());
294 }
295
296 #[test]
297 fn test_different_indices_produce_different_ciphertexts() {
298 let key = LogEncryptionKey::new([0xab; 32]);
299 let encryptor = EntryEncryptor::new(key);
300 let plaintext = b"same plaintext for both entries";
301
302 let payload1 = encryptor.encrypt(1, plaintext).expect("encrypt entry 1");
303 let payload2 = encryptor.encrypt(2, plaintext).expect("encrypt entry 2");
304
305 assert_ne!(payload1.ciphertext, payload2.ciphertext);
306 assert_ne!(payload1.nonce, payload2.nonce);
307 }
308
309 #[test]
310 fn test_hmac_verify_valid() {
311 let key = [0x12u8; 32];
312 let verifier = LogIntegrityVerifier::new(key);
313 let payload = EncryptedPayload {
314 ciphertext: vec![0xde, 0xad, 0xbe, 0xef],
315 nonce: [0u8; 12],
316 };
317
318 let tag = verifier.compute(7, &payload);
319 verifier
320 .verify(7, &payload, &tag)
321 .expect("HMAC should verify successfully");
322 }
323
324 #[test]
325 fn test_hmac_verify_tampered_fails() {
326 let key = [0x34u8; 32];
327 let verifier = LogIntegrityVerifier::new(key);
328 let mut payload = EncryptedPayload {
329 ciphertext: vec![0x01, 0x02, 0x03, 0x04, 0x05],
330 nonce: [0u8; 12],
331 };
332
333 let tag = verifier.compute(99, &payload);
334
335 payload.ciphertext[2] ^= 0xff;
337
338 let result = verifier.verify(99, &payload, &tag);
339 assert!(
340 result.is_err(),
341 "verification of tampered payload should fail"
342 );
343 }
344
345 #[test]
346 fn test_key_from_slice_wrong_length() {
347 let too_short = [0u8; 16];
348 assert!(
349 LogEncryptionKey::from_slice(&too_short).is_err(),
350 "should reject a 16-byte slice"
351 );
352
353 let too_long = [0u8; 64];
354 assert!(
355 LogEncryptionKey::from_slice(&too_long).is_err(),
356 "should reject a 64-byte slice"
357 );
358
359 let correct = [0u8; 32];
360 assert!(
361 LogEncryptionKey::from_slice(&correct).is_ok(),
362 "should accept a 32-byte slice"
363 );
364 }
365
366 #[test]
367 fn test_encrypt_empty_plaintext() {
368 let key = LogEncryptionKey::new([0xcc; 32]);
369 let encryptor = EntryEncryptor::new(key);
370
371 let payload = encryptor
372 .encrypt(0, b"")
373 .expect("encrypting empty plaintext should succeed");
374 let decrypted = encryptor
375 .decrypt(0, &payload)
376 .expect("decrypting empty ciphertext should succeed");
377
378 assert!(
379 decrypted.is_empty(),
380 "round-tripped empty plaintext must be empty"
381 );
382 }
383}