dynomite/entropy/mod.rs
1//! Entropy reconciliation channel.
2//!
3//! The entropy module ships a snapshot of the local datastore to a
4//! peer reconciliation engine (and vice versa) over a TCP
5//! connection. Each chunk of the snapshot is encrypted with
6//! AES-128-CBC using a pre-shared key and IV loaded from the
7//! conf-configured `recon_key.pem` and `recon_iv.pem` files. The
8//! key and IV used here are independent from the per-peer
9//! AES session key established during the DNODE handshake; the
10//! entropy channel uses its own pre-shared material.
11//!
12//! # Layout
13//!
14//! * [`util`] holds the on-disk key/IV loaders and validated
15//! wrappers.
16//! * [`send`] drives the client side: connect, write the
17//! negotiation header, stream the snapshot.
18//! * [`receive`] drives the server side: bind, accept, replay the
19//! plaintext into a [`SnapshotSink`].
20//! * The top-level [`EntropyConfig`] gathers the operator-visible
21//! knobs.
22//!
23//! # Wire format
24//!
25//! The reconciliation protocol uses a single, length-prefixed
26//! framing that is symmetric across sender and receiver. See
27//! [`docs/parity.md`](../../../docs/parity.md) for the precise
28//! divergence list.
29//!
30//! ```text
31//! Negotiation header (20 bytes, big-endian u32 fields):
32//! magic = 0x64640001
33//! command = 1 (SEND: data flowing sender -> receiver)
34//! header_size = N (size in bytes of the snapshot header below)
35//! buffer_size = M (max plaintext bytes per chunk)
36//! cipher_size = C (max ciphertext bytes per chunk)
37//!
38//! Snapshot header (header_size bytes, big-endian u32 fields then
39//! zero-padded):
40//! total_len = L (total plaintext length in bytes)
41//! encrypt_flag = 0|1 (1 if chunks are AES-128-CBC encrypted)
42//! <header_size - 8 bytes of zero padding>
43//!
44//! Chunks (repeated until L plaintext bytes have been delivered):
45//! u32 BE chunk_len
46//! <chunk_len bytes of payload>
47//!
48//! When encrypt_flag is set each payload is the AES-128-CBC
49//! ciphertext of the matching plaintext chunk, PKCS#7-padded to a
50//! 16-byte boundary; the receiver strips the padding before
51//! delivering bytes to its sink.
52//! ```
53//!
54//! # Example
55//!
56//! ```no_run
57//! use std::path::PathBuf;
58//! use dynomite::entropy::{EntropyConfig, EntropyReceiver, EntropySender};
59//!
60//! let cfg = EntropyConfig {
61//! key_file: PathBuf::from("/etc/dynomite/recon_key.pem"),
62//! iv_file: PathBuf::from("/etc/dynomite/recon_iv.pem"),
63//! listen_addr: "127.0.0.1:8105".parse().unwrap(),
64//! send_addr: None,
65//! peer_endpoint: "127.0.0.1:8105".parse().unwrap(),
66//! buffer_size: 16 * 1024,
67//! header_size: 1024,
68//! encrypt: true,
69//! };
70//! drop(cfg);
71//! ```
72
73pub mod driver;
74pub mod receive;
75pub mod send;
76pub mod util;
77
78use std::io;
79use std::net::SocketAddr;
80use std::path::PathBuf;
81use std::sync::Arc;
82
83use thiserror::Error;
84
85pub use crate::entropy::receive::EntropyReceiver;
86pub use crate::entropy::send::{EntropySender, RedisLocalSnapshot};
87pub use crate::entropy::util::{
88 EntropyIv, EntropyKey, EntropyMaterial, ENTROPY_IV_LEN, ENTROPY_KEY_LEN,
89};
90
91/// Box a [`SnapshotSource`] into the [`BoxedSnapshotSource`] alias
92/// expected by [`EntropySender::run`].
93///
94/// # Examples
95///
96/// ```
97/// use dynomite::entropy::{boxed_source, send::StaticSnapshot, SnapshotSource};
98/// let s = boxed_source(StaticSnapshot::new(vec![1, 2, 3]));
99/// assert_eq!(s.snapshot().unwrap(), vec![1, 2, 3]);
100/// ```
101#[must_use]
102pub fn boxed_source<S: SnapshotSource + 'static>(source: S) -> BoxedSnapshotSource {
103 Arc::new(source)
104}
105
106/// Box a [`SnapshotSink`] into the [`BoxedSnapshotSink`] alias
107/// expected by [`EntropyReceiver::run`].
108///
109/// # Examples
110///
111/// ```
112/// use dynomite::entropy::{boxed_sink, receive::MemorySink, SnapshotSink};
113/// let sink = boxed_sink(MemorySink::default());
114/// sink.apply(b"hi").unwrap();
115/// ```
116#[must_use]
117pub fn boxed_sink<S: SnapshotSink + 'static>(sink: S) -> BoxedSnapshotSink {
118 Arc::new(sink)
119}
120
121/// Magic word that opens every entropy negotiation header.
122///
123/// The magic word (`64640001`) that opens every entropy
124/// negotiation header.
125pub const ENTROPY_MAGIC: u32 = 0x6464_0001;
126
127/// Negotiation command: sender pushes a snapshot to the receiver.
128pub const ENTROPY_COMMAND_SEND: u32 = 1;
129
130/// Default plaintext chunk size, in bytes (16 KiB).
131pub const DEFAULT_BUFFER_SIZE: usize = 16 * 1024;
132
133/// Default ciphertext chunk capacity, in bytes. The reference
134/// engine reserves an extra 1 KiB above the plaintext buffer to
135/// hold cipher overhead; we mirror that headroom, which is far
136/// more than PKCS#7 needs.
137pub const DEFAULT_CIPHER_SIZE: usize = DEFAULT_BUFFER_SIZE + 1024;
138
139/// Default snapshot header size, in bytes. Matches the reference
140/// engine's `MAX_HEADER_SIZE`.
141pub const DEFAULT_HEADER_SIZE: usize = 1024;
142
143/// Hard ceiling on the negotiated `header_size`, in bytes.
144///
145/// Hard ceiling validated on the negotiation header size.
146pub const MAX_HEADER_SIZE: usize = 1024;
147
148/// Hard ceiling on the negotiated `buffer_size`, in bytes (5 MiB).
149pub const MAX_BUFFER_SIZE: usize = 5 * 1024 * 1024;
150
151/// Hard ceiling on the negotiated `cipher_size`, in bytes (5 MiB).
152pub const MAX_CIPHER_SIZE: usize = 5 * 1024 * 1024;
153
154/// Hard ceiling on a single snapshot's plaintext size, in bytes (4 GiB).
155///
156/// This bound exists because the receiver stages every chunk into a
157/// `Vec<u8>` for replay and so MUST cap the upfront allocation. A
158/// malicious sender that completes the negotiation handshake could
159/// otherwise declare `total_len = u32::MAX` and trigger a 4 GiB
160/// allocation attempt before any payload bytes arrive. The cap below
161/// is a generous practical ceiling (4 GiB minus 1) that still lets
162/// large RDB snapshots through; embedders can plug their own
163/// `SnapshotSink` to apply tighter bounds.
164pub const MAX_SNAPSHOT_SIZE: usize = u32::MAX as usize - 1;
165
166/// Cap the receiver's pre-allocation hint to avoid a malicious or
167/// malformed `total_len` triggering an oversized allocation up
168/// front. Real RDB snapshots stream chunk-by-chunk; the receiver
169/// reallocates as plaintext arrives if the snapshot is genuinely
170/// larger than the hint.
171pub const SAFE_PREALLOC: usize = 16 * 1024 * 1024;
172
173/// Operator-facing configuration for the entropy worker.
174///
175/// # Examples
176///
177/// ```
178/// use std::path::PathBuf;
179/// use dynomite::entropy::EntropyConfig;
180/// let cfg = EntropyConfig {
181/// key_file: PathBuf::from("conf/recon_key.pem"),
182/// iv_file: PathBuf::from("conf/recon_iv.pem"),
183/// listen_addr: "127.0.0.1:8105".parse().unwrap(),
184/// send_addr: None,
185/// peer_endpoint: "127.0.0.1:8105".parse().unwrap(),
186/// buffer_size: 16 * 1024,
187/// header_size: 1024,
188/// encrypt: true,
189/// };
190/// assert_eq!(cfg.buffer_size, 16 * 1024);
191/// ```
192#[derive(Debug, Clone)]
193pub struct EntropyConfig {
194 /// On-disk path to the AES-128 key file.
195 pub key_file: PathBuf,
196 /// On-disk path to the AES-128-CBC IV file.
197 pub iv_file: PathBuf,
198 /// Address the [`EntropyReceiver`] binds to.
199 pub listen_addr: SocketAddr,
200 /// Optional local bind address for the [`EntropySender`]. When
201 /// `None` the kernel assigns an ephemeral port on the wildcard
202 /// address.
203 pub send_addr: Option<SocketAddr>,
204 /// Address the [`EntropySender`] dials.
205 pub peer_endpoint: SocketAddr,
206 /// Plaintext chunk size (bytes). Must be a multiple of 16 when
207 /// encryption is enabled and must not exceed
208 /// [`MAX_BUFFER_SIZE`].
209 pub buffer_size: usize,
210 /// Snapshot header size (bytes). Must not exceed
211 /// [`MAX_HEADER_SIZE`].
212 pub header_size: usize,
213 /// Whether per-chunk payloads are AES-128-CBC encrypted.
214 pub encrypt: bool,
215}
216
217impl EntropyConfig {
218 /// Validate the cross-field invariants the protocol demands.
219 ///
220 /// # Errors
221 /// [`EntropyError::Config`] when any invariant is violated.
222 ///
223 /// # Examples
224 ///
225 /// ```
226 /// use std::path::PathBuf;
227 /// use dynomite::entropy::EntropyConfig;
228 /// let mut cfg = EntropyConfig {
229 /// key_file: PathBuf::from("k"),
230 /// iv_file: PathBuf::from("v"),
231 /// listen_addr: "127.0.0.1:0".parse().unwrap(),
232 /// send_addr: None,
233 /// peer_endpoint: "127.0.0.1:0".parse().unwrap(),
234 /// buffer_size: 32,
235 /// header_size: 64,
236 /// encrypt: true,
237 /// };
238 /// assert!(cfg.validate().is_ok());
239 /// cfg.buffer_size = 0;
240 /// assert!(cfg.validate().is_err());
241 /// ```
242 pub fn validate(&self) -> Result<(), EntropyError> {
243 if self.buffer_size == 0 || self.buffer_size > MAX_BUFFER_SIZE {
244 return Err(EntropyError::Config(format!(
245 "buffer_size {} out of range (1..={MAX_BUFFER_SIZE})",
246 self.buffer_size
247 )));
248 }
249 if self.header_size < 8 || self.header_size > MAX_HEADER_SIZE {
250 return Err(EntropyError::Config(format!(
251 "header_size {} out of range (8..={MAX_HEADER_SIZE})",
252 self.header_size
253 )));
254 }
255 if self.encrypt && !self.buffer_size.is_multiple_of(16) {
256 return Err(EntropyError::Config(format!(
257 "buffer_size {} must be a multiple of 16 with encryption enabled",
258 self.buffer_size
259 )));
260 }
261 Ok(())
262 }
263}
264
265/// Snapshot byte source.
266///
267/// Implementations are pluggable via the
268/// embedding API; the engine ships [`RedisLocalSnapshot`] as the
269/// default. Implementations are expected to be cheap to clone
270/// (e.g. shared via [`Arc`]) but each call to [`snapshot`] may
271/// produce a different blob.
272///
273/// [`snapshot`]: SnapshotSource::snapshot
274pub trait SnapshotSource: Send + Sync {
275 /// Produce one snapshot of the local state as a contiguous
276 /// byte buffer. The sender treats the bytes as opaque; the
277 /// receiver replays them through its [`SnapshotSink`].
278 ///
279 /// # Errors
280 /// Implementation-defined.
281 fn snapshot(&self) -> Result<Vec<u8>, EntropyError>;
282}
283
284/// Boxed [`SnapshotSource`] handed to [`EntropySender`].
285pub type BoxedSnapshotSource = Arc<dyn SnapshotSource>;
286
287impl<T> SnapshotSource for Arc<T>
288where
289 T: SnapshotSource + ?Sized,
290{
291 fn snapshot(&self) -> Result<Vec<u8>, EntropyError> {
292 (**self).snapshot()
293 }
294}
295
296/// Receiver-side hook that consumes the decrypted snapshot.
297///
298/// Implementations are pluggable via the
299/// embedding API. The engine ships an in-memory implementation
300/// for tests and the [`receive::RedisReplaySink`] default for
301/// production wiring.
302pub trait SnapshotSink: Send + Sync {
303 /// Apply the full plaintext snapshot to the local datastore.
304 /// Called exactly once per connection.
305 ///
306 /// # Errors
307 /// Implementation-defined.
308 fn apply(&self, snapshot: &[u8]) -> Result<(), EntropyError>;
309}
310
311/// Boxed [`SnapshotSink`] handed to [`EntropyReceiver`].
312pub type BoxedSnapshotSink = Arc<dyn SnapshotSink>;
313
314impl<T> SnapshotSink for Arc<T>
315where
316 T: SnapshotSink + ?Sized,
317{
318 fn apply(&self, snapshot: &[u8]) -> Result<(), EntropyError> {
319 (**self).apply(snapshot)
320 }
321}
322
323/// Errors raised by the entropy module.
324#[derive(Debug, Error)]
325pub enum EntropyError {
326 /// I/O or socket failure.
327 #[error("entropy io: {0}")]
328 Io(#[from] io::Error),
329 /// Invalid configuration.
330 #[error("entropy config: {0}")]
331 Config(String),
332 /// Key or IV material is missing or malformed.
333 #[error("entropy key material: {0}")]
334 KeyMaterial(String),
335 /// Wire-protocol violation.
336 #[error("entropy protocol: {0}")]
337 Protocol(String),
338 /// AES-CBC decryption failure (bad padding or wrong key).
339 #[error("entropy crypto: {0}")]
340 Crypto(String),
341 /// Snapshot source produced an error.
342 #[error("entropy source: {0}")]
343 Source(String),
344 /// Snapshot sink rejected the replay.
345 #[error("entropy sink: {0}")]
346 Sink(String),
347}
348
349/// Convenience type alias.
350pub type EntropyResult<T> = Result<T, EntropyError>;
351
352/// Negotiation header that opens every entropy connection.
353///
354/// All five fields are 32-bit big-endian unsigned integers on the
355/// wire.
356#[derive(Debug, Clone, Copy, Eq, PartialEq)]
357pub struct NegotiationHeader {
358 /// Magic word; must equal [`ENTROPY_MAGIC`].
359 pub magic: u32,
360 /// Direction marker. [`ENTROPY_COMMAND_SEND`] = data flows
361 /// from sender to receiver.
362 pub command: u32,
363 /// Snapshot-header size, in bytes.
364 pub header_size: u32,
365 /// Plaintext chunk size, in bytes.
366 pub buffer_size: u32,
367 /// Ciphertext chunk capacity, in bytes.
368 pub cipher_size: u32,
369}
370
371impl NegotiationHeader {
372 /// Wire size in bytes.
373 pub const SIZE: usize = 5 * 4;
374
375 /// Encode the header to wire bytes.
376 #[must_use]
377 pub fn to_wire(self) -> [u8; Self::SIZE] {
378 let mut out = [0u8; Self::SIZE];
379 out[0..4].copy_from_slice(&self.magic.to_be_bytes());
380 out[4..8].copy_from_slice(&self.command.to_be_bytes());
381 out[8..12].copy_from_slice(&self.header_size.to_be_bytes());
382 out[12..16].copy_from_slice(&self.buffer_size.to_be_bytes());
383 out[16..20].copy_from_slice(&self.cipher_size.to_be_bytes());
384 out
385 }
386
387 /// Decode and validate a wire-format negotiation header.
388 ///
389 /// # Errors
390 /// [`EntropyError::Protocol`] if any field is out of range or
391 /// the magic word is wrong.
392 pub fn from_wire(bytes: &[u8; Self::SIZE]) -> Result<Self, EntropyError> {
393 let magic = u32::from_be_bytes(bytes[0..4].try_into().unwrap());
394 let command = u32::from_be_bytes(bytes[4..8].try_into().unwrap());
395 let header_size = u32::from_be_bytes(bytes[8..12].try_into().unwrap());
396 let buffer_size = u32::from_be_bytes(bytes[12..16].try_into().unwrap());
397 let cipher_size = u32::from_be_bytes(bytes[16..20].try_into().unwrap());
398 if magic != ENTROPY_MAGIC {
399 return Err(EntropyError::Protocol(format!(
400 "bad magic 0x{magic:08x}, expected 0x{ENTROPY_MAGIC:08x}"
401 )));
402 }
403 if command != ENTROPY_COMMAND_SEND {
404 return Err(EntropyError::Protocol(format!(
405 "unsupported command {command}"
406 )));
407 }
408 if header_size < 8 || header_size as usize > MAX_HEADER_SIZE {
409 return Err(EntropyError::Protocol(format!(
410 "header_size {header_size} out of range"
411 )));
412 }
413 if buffer_size == 0 || buffer_size as usize > MAX_BUFFER_SIZE {
414 return Err(EntropyError::Protocol(format!(
415 "buffer_size {buffer_size} out of range"
416 )));
417 }
418 if cipher_size == 0 || cipher_size as usize > MAX_CIPHER_SIZE {
419 return Err(EntropyError::Protocol(format!(
420 "cipher_size {cipher_size} out of range"
421 )));
422 }
423 Ok(Self {
424 magic,
425 command,
426 header_size,
427 buffer_size,
428 cipher_size,
429 })
430 }
431}
432
433/// Per-snapshot header carried inside the variable-sized header
434/// region declared by the negotiation step.
435///
436/// The first eight bytes are two big-endian `u32`s: the total
437/// plaintext length and the encrypt flag. The remaining bytes are
438/// reserved-for-future-use and must be transmitted as zero.
439#[derive(Debug, Clone, Copy, Eq, PartialEq)]
440pub struct SnapshotHeader {
441 /// Total plaintext length, in bytes.
442 pub total_len: u32,
443 /// `1` when chunks are AES-128-CBC encrypted, `0` otherwise.
444 pub encrypt_flag: u32,
445}
446
447impl SnapshotHeader {
448 /// Encode into a `header_size`-byte buffer.
449 ///
450 /// # Errors
451 /// [`EntropyError::Config`] if `header_size` is shorter than
452 /// the eight-byte fixed prefix.
453 pub fn to_wire(self, header_size: usize) -> Result<Vec<u8>, EntropyError> {
454 if header_size < 8 {
455 return Err(EntropyError::Config(format!(
456 "header_size {header_size} smaller than fixed 8-byte prefix"
457 )));
458 }
459 let mut out = vec![0u8; header_size];
460 out[0..4].copy_from_slice(&self.total_len.to_be_bytes());
461 out[4..8].copy_from_slice(&self.encrypt_flag.to_be_bytes());
462 Ok(out)
463 }
464
465 /// Decode from a `header_size`-byte buffer.
466 ///
467 /// # Errors
468 /// [`EntropyError::Protocol`] if the buffer is shorter than
469 /// the fixed prefix or carries an unknown `encrypt_flag`.
470 pub fn from_wire(bytes: &[u8]) -> Result<Self, EntropyError> {
471 if bytes.len() < 8 {
472 return Err(EntropyError::Protocol(format!(
473 "snapshot header too short ({} bytes)",
474 bytes.len()
475 )));
476 }
477 let total_len = u32::from_be_bytes(bytes[0..4].try_into().unwrap());
478 let encrypt_flag = u32::from_be_bytes(bytes[4..8].try_into().unwrap());
479 if encrypt_flag > 1 {
480 return Err(EntropyError::Protocol(format!(
481 "unknown encrypt_flag {encrypt_flag}"
482 )));
483 }
484 Ok(Self {
485 total_len,
486 encrypt_flag,
487 })
488 }
489}
490
491#[cfg(test)]
492mod tests {
493 use super::*;
494
495 #[test]
496 fn negotiation_header_round_trips() {
497 let hdr = NegotiationHeader {
498 magic: ENTROPY_MAGIC,
499 command: ENTROPY_COMMAND_SEND,
500 header_size: 1024,
501 buffer_size: 16 * 1024,
502 cipher_size: 17 * 1024,
503 };
504 let wire = hdr.to_wire();
505 let parsed = NegotiationHeader::from_wire(&wire).unwrap();
506 assert_eq!(parsed, hdr);
507 }
508
509 #[test]
510 fn negotiation_header_rejects_bad_magic() {
511 let mut wire = NegotiationHeader {
512 magic: 0xdead_beef,
513 command: ENTROPY_COMMAND_SEND,
514 header_size: 1024,
515 buffer_size: 16 * 1024,
516 cipher_size: 17 * 1024,
517 }
518 .to_wire();
519 // Force-write the bad magic in case the constructor ever changes.
520 wire[0..4].copy_from_slice(&0xdead_beefu32.to_be_bytes());
521 let err = NegotiationHeader::from_wire(&wire).unwrap_err();
522 assert!(matches!(err, EntropyError::Protocol(_)));
523 }
524
525 #[test]
526 fn snapshot_header_round_trips() {
527 let hdr = SnapshotHeader {
528 total_len: 4096,
529 encrypt_flag: 1,
530 };
531 let wire = hdr.to_wire(64).unwrap();
532 assert_eq!(wire.len(), 64);
533 for byte in &wire[8..] {
534 assert_eq!(*byte, 0);
535 }
536 let parsed = SnapshotHeader::from_wire(&wire).unwrap();
537 assert_eq!(parsed, hdr);
538 }
539
540 #[test]
541 fn snapshot_header_rejects_bad_flag() {
542 let mut wire = vec![0u8; 16];
543 wire[4..8].copy_from_slice(&5u32.to_be_bytes());
544 let err = SnapshotHeader::from_wire(&wire).unwrap_err();
545 assert!(matches!(err, EntropyError::Protocol(_)));
546 }
547
548 #[test]
549 fn config_validate_rejects_zero_buffer() {
550 let cfg = EntropyConfig {
551 key_file: PathBuf::from("k"),
552 iv_file: PathBuf::from("v"),
553 listen_addr: "127.0.0.1:0".parse().unwrap(),
554 send_addr: None,
555 peer_endpoint: "127.0.0.1:0".parse().unwrap(),
556 buffer_size: 0,
557 header_size: 64,
558 encrypt: true,
559 };
560 assert!(cfg.validate().is_err());
561 }
562}