Skip to main content

amaters_cluster/
encryption.rs

1//! AES-256-GCM encryption and HMAC-SHA256 integrity for Raft log payloads.
2//!
3//! This module provides per-entry encryption using HKDF-derived keys and nonces,
4//! plus HMAC-based integrity verification for the encrypted log chain.
5//!
6//! ## Design
7//!
8//! - Each log entry's AES-256-GCM key **and** nonce are deterministically derived from
9//!   the master key and the entry index via HKDF-SHA256, so no nonce reuse is possible
10//!   within a key epoch.
11//! - HMAC-SHA256 is computed over `entry_index_le || nonce || ciphertext` to provide
12//!   additional chain integrity beyond what GCM authentication already gives.
13
14use aes_gcm::aead::{Aead, KeyInit};
15use aes_gcm::{Aes256Gcm, Key, Nonce};
16use hkdf::Hkdf;
17use hmac::{Hmac, Mac};
18use sha2::Sha256;
19
20// Bring KeyInit into scope explicitly so disambiguating `<HmacSha256 as KeyInit>::new_from_slice`
21// is not needed at every call site.  We re-alias it to avoid shadowing `hmac::Mac`.
22
23use crate::error::{RaftError, RaftResult};
24
25type HmacSha256 = Hmac<Sha256>;
26
27// ──────────────────────────────────────────────
28// LogEncryptionKey
29// ──────────────────────────────────────────────
30
31/// A 32-byte master key used to derive per-entry AES-256-GCM keys and nonces.
32pub struct LogEncryptionKey {
33    key_bytes: [u8; 32],
34}
35
36impl LogEncryptionKey {
37    /// Create a [`LogEncryptionKey`] from a raw 32-byte array.
38    pub fn new(key_bytes: [u8; 32]) -> Self {
39        Self { key_bytes }
40    }
41
42    /// Create a [`LogEncryptionKey`] from a byte slice.
43    ///
44    /// # Errors
45    /// Returns [`RaftError::StorageError`] when `bytes.len() != 32`.
46    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    /// Generate a random [`LogEncryptionKey`] without an external RNG crate.
57    ///
58    /// Entropy comes from four independent `std::collections::hash_map::RandomState`
59    /// instances (each OS-seeded) mixed with the current nanosecond timestamp,
60    /// then stretched to 32 bytes via HKDF-SHA256.
61    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        // Four independently OS-seeded instances give us independent hash states.
72        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            // XOR with a large constant to decorrelate from h1
85            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        // Assemble 32-byte IKM from the four hash outputs.
102        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        // HKDF expand for 32 bytes of output with SHA-256 can never exceed the limit.
112        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// ──────────────────────────────────────────────
120// EncryptedPayload
121// ──────────────────────────────────────────────
122
123/// The encrypted form of a single Raft log entry payload.
124#[derive(Debug, Clone)]
125pub struct EncryptedPayload {
126    /// Ciphertext produced by AES-256-GCM, including the 16-byte authentication tag.
127    pub ciphertext: Vec<u8>,
128    /// The 12-byte nonce used during encryption (derived from master key + entry index).
129    pub nonce: [u8; 12],
130}
131
132// ──────────────────────────────────────────────
133// EntryEncryptor
134// ──────────────────────────────────────────────
135
136/// Encrypts and decrypts Raft log entry payloads using AES-256-GCM.
137///
138/// The AES key **and** nonce for each entry are deterministically derived from
139/// the master key and the entry index via HKDF-SHA256, ensuring unique key material
140/// per entry without the need for a random nonce.
141pub struct EntryEncryptor {
142    master_key: LogEncryptionKey,
143}
144
145impl EntryEncryptor {
146    /// Create a new [`EntryEncryptor`] backed by `key`.
147    pub fn new(key: LogEncryptionKey) -> Self {
148        Self { master_key: key }
149    }
150
151    /// Derive the per-entry AES-256-GCM key (32 bytes) and nonce (12 bytes).
152    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]; // 32 bytes key + 12 bytes nonce
155        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    /// Encrypt `plaintext` associated with `entry_index`.
168    ///
169    /// The returned [`EncryptedPayload`] contains the GCM ciphertext (with auth tag)
170    /// and the nonce that was used.
171    ///
172    /// # Errors
173    /// Returns [`RaftError::StorageError`] on any cryptographic failure.
174    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    /// Decrypt `payload` associated with `entry_index`.
195    ///
196    /// The AES key is re-derived from the master key and `entry_index`.
197    /// The nonce stored in the payload is used for decryption.
198    ///
199    /// # Errors
200    /// Returns [`RaftError::StorageError`] on key derivation failure or GCM
201    /// authentication failure (including tampered ciphertext).
202    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
217// ──────────────────────────────────────────────
218// LogIntegrityVerifier
219// ──────────────────────────────────────────────
220
221/// HMAC-SHA256 integrity verifier for encrypted Raft log entries.
222///
223/// Computes and verifies HMAC-SHA256 over `entry_index_le || nonce || ciphertext`,
224/// providing additional chain integrity on top of GCM authentication.
225pub struct LogIntegrityVerifier {
226    key: [u8; 32],
227}
228
229impl LogIntegrityVerifier {
230    /// Create a new [`LogIntegrityVerifier`] with a 32-byte HMAC key.
231    pub fn new(key: [u8; 32]) -> Self {
232        Self { key }
233    }
234
235    /// Compute HMAC-SHA256 over `entry_index_le || nonce || ciphertext`.
236    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    /// Verify `tag` against the HMAC of `payload` using constant-time comparison.
250    ///
251    /// # Errors
252    /// Returns [`RaftError::StorageError`] when the tag does not match.
253    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        // `verify_slice` performs a constant-time comparison internally.
266        mac.verify_slice(tag).map_err(|_| RaftError::StorageError {
267            message: "HMAC-SHA256 integrity verification failed: tag mismatch".to_string(),
268        })
269    }
270}
271
272// ──────────────────────────────────────────────
273// Tests
274// ──────────────────────────────────────────────
275
276#[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        // Flip one bit in the ciphertext to simulate tampering.
336        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}