Skip to main content

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}