Skip to main content

segment_buffer/
cipher.rs

1//! Pluggable encryption for segment files at rest.
2//!
3//! The [`SegmentCipher`] trait abstracts the encrypt/decrypt operations so
4//! callers can bring any AEAD (AES-GCM, ChaCha20-Poly1305, etc.). When the
5//! `encryption` feature is enabled, a ready-made [`AesGcmCipher`] is provided.
6//!
7//! Cipher implementations return the lightweight [`CipherError`] so they don't
8//! need to know about segment paths or the wider [`crate::SegmentError`]
9//! hierarchy. The segment I/O layer attaches path context when promoting a
10//! [`CipherError`] to a [`crate::SegmentError::Cipher`].
11
12use std::fmt;
13use std::sync::Arc;
14
15/// Error returned by [`SegmentCipher`] implementations.
16///
17/// Deliberately minimal: the cipher operates on bytes, not files, so it has no
18/// path or sequence context to carry. The segment I/O layer enriches this into
19/// a [`crate::SegmentError::Cipher`] with the offending file's path.
20///
21/// Construct with [`CipherError::msg`] for a plain message, or
22/// [`CipherError::with_source`] when you want to preserve the underlying AEAD
23/// (or other) error type for `std::error::Error::source()` chaining. The
24/// fields are private so that adding context later is non-breaking.
25#[derive(Debug, Clone)]
26pub struct CipherError {
27    /// Human-readable description of what went wrong.
28    message: String,
29    /// Optional underlying cause (e.g. the AEAD crate's opaque error).
30    /// `Arc` (not `Box`) so [`CipherError`] stays [`Clone`]. Surfaced via
31    /// [`std::error::Error::source`].
32    source: Option<Arc<dyn std::error::Error + Send + Sync>>,
33}
34
35impl CipherError {
36    /// Construct a [`CipherError`] from anything displayable, with no
37    /// underlying cause.
38    ///
39    /// # Example
40    ///
41    /// ```
42    /// use segment_buffer::CipherError;
43    ///
44    /// let err = CipherError::msg("key not configured");
45    /// assert_eq!(err.to_string(), "key not configured");
46    /// assert!(std::error::Error::source(&err).is_none());
47    /// ```
48    pub fn msg(message: impl fmt::Display) -> Self {
49        Self {
50            message: message.to_string(),
51            source: None,
52        }
53    }
54
55    /// Construct a [`CipherError`] that preserves the underlying error so
56    /// operators can inspect it via [`std::error::Error::source`].
57    ///
58    /// Use this when wrapping a typed error from an AEAD implementation
59    /// (`aes_gcm::Error`, `chacha20poly1305::Error`, …) so the original
60    /// failure is not erased behind a `format!`.
61    ///
62    /// # Example
63    ///
64    /// ```
65    /// use segment_buffer::CipherError;
66    /// use std::fmt;
67    /// use std::error::Error;
68    ///
69    /// /// A tiny typed error an AEAD crate might expose.
70    /// #[derive(Debug)]
71    /// struct AeadError;
72    /// impl fmt::Display for AeadError {
73    ///     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
74    ///         f.write_str("tag mismatch")
75    ///     }
76    /// }
77    /// impl std::error::Error for AeadError {}
78    ///
79    /// let err = CipherError::with_source("AES-GCM decryption failed", AeadError);
80    /// assert_eq!(err.to_string(), "AES-GCM decryption failed");
81    /// // The underlying cause is preserved via `source()`:
82    /// let src = err.source().expect("source should be set by with_source");
83    /// assert_eq!(src.to_string(), "tag mismatch");
84    /// ```
85    pub fn with_source<E>(message: impl fmt::Display, source: E) -> Self
86    where
87        E: std::error::Error + Send + Sync + 'static,
88    {
89        Self {
90            message: message.to_string(),
91            source: Some(Arc::new(source)),
92        }
93    }
94}
95
96impl fmt::Display for CipherError {
97    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
98        f.write_str(&self.message)
99    }
100}
101
102impl std::error::Error for CipherError {
103    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
104        // Trait-upcasting coercion (stable since Rust 1.86) turns
105        // `&(dyn Error + Send + Sync)` into `&dyn Error`. The explicit
106        // closure return type forces the coercion through `Option::map`.
107        self.source
108            .as_deref()
109            .map(|s| -> &(dyn std::error::Error + 'static) { s })
110    }
111}
112
113/// Encrypts and decrypts segment file payloads.
114///
115/// Implementations must be [`Send`] + [`Sync`] because the buffer is shared
116/// across threads via `Arc<SegmentBuffer>`.
117///
118/// The ciphertext format is implementation-defined but must be self-describing:
119/// [`decrypt`](Self::decrypt) must be able to recover the plaintext from the
120/// exact bytes returned by [`encrypt`](Self::encrypt) without external state.
121///
122/// # Naming
123///
124/// The trait is called `SegmentCipher`, not `SegmentAead`, even though the
125/// shipped implementation (the `AesGcmCipher` behind the `encryption` feature)
126/// is an AEAD. This is deliberate: the trait contract is "any stateless
127/// self-describing encrypt/decrypt pair", which admits AEADs (recommended),
128/// HMAC-wrapped symmetric ciphers, or even custom schemes that combine
129/// encryption with a separate authenticator. Renaming to `SegmentAead` would
130/// narrow the contract to AEADs only — a constraint the trait does not actually
131/// enforce. Use an AEAD in practice; the trait stays general on purpose.
132///
133/// # Example
134///
135/// ```
136/// use segment_buffer::{CipherError, SegmentCipher};
137///
138/// struct Rot13;
139///
140/// impl SegmentCipher for Rot13 {
141///     fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>, CipherError> {
142///         Ok(plaintext.iter().map(|b| b.wrapping_add(13)).collect())
143///     }
144///     fn decrypt(&self, ciphertext: &[u8]) -> Result<Vec<u8>, CipherError> {
145///         Ok(ciphertext.iter().map(|b| b.wrapping_sub(13)).collect())
146///     }
147/// }
148/// ```
149pub trait SegmentCipher: Send + Sync {
150    /// Encrypt `plaintext`, returning self-describing ciphertext.
151    ///
152    /// # Errors
153    ///
154    /// Returns [`CipherError`] if the cipher fails to encrypt (e.g. RNG
155    /// failure, internal AEAD error). Implementations must be deterministic
156    /// in their failure modes — the same plaintext either always succeeds or
157    /// always fails.
158    fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>, CipherError>;
159
160    /// Decrypt previously-produced ciphertext back to the original plaintext.
161    ///
162    /// # Errors
163    ///
164    /// Returns [`CipherError`] if the ciphertext is too short for the cipher's
165    /// nonce, the authentication tag does not verify (wrong key or tampering),
166    /// or the underlying AEAD reports a decryption failure.
167    fn decrypt(&self, ciphertext: &[u8]) -> Result<Vec<u8>, CipherError>;
168}
169
170// ---------------------------------------------------------------------------
171// AES-256-GCM implementation (behind the `encryption` feature)
172// ---------------------------------------------------------------------------
173
174#[cfg(feature = "encryption")]
175mod private {
176    use super::{CipherError, SegmentCipher};
177    use std::fmt;
178    use std::sync::Arc;
179
180    /// Wrapper that turns any `Display`able AEAD error (e.g. the opaque
181    /// `aes_gcm::Error`, which intentionally does not impl `std::error::Error`)
182    /// into something that does, so it can flow through
183    /// [`std::error::Error::source`] chains without losing the original
184    /// diagnostic message.
185    #[derive(Debug, Clone)]
186    struct AeadError(String);
187
188    impl fmt::Display for AeadError {
189        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
190            f.write_str(&self.0)
191        }
192    }
193
194    impl std::error::Error for AeadError {}
195
196    fn wrap<E: fmt::Display>(message: &'static str, e: E) -> CipherError {
197        CipherError {
198            message: message.to_string(),
199            source: Some(Arc::new(AeadError(e.to_string()))),
200        }
201    }
202
203    /// AES-256-GCM cipher with a random 12-byte nonce prepended to each ciphertext.
204    ///
205    /// The on-disk payload format is: `[12-byte nonce][ciphertext + 16-byte GCM tag]`.
206    /// This is byte-compatible with monitor365's `EncryptionKey` segment format,
207    /// so existing encrypted segments can be read without migration. (The segment
208    /// file envelope, if present, is stripped before the cipher sees the bytes.)
209    pub struct AesGcmCipher {
210        cipher: aes_gcm::Aes256Gcm,
211    }
212
213    impl AesGcmCipher {
214        /// Create a new cipher from a 32-byte AES-256 key.
215        ///
216        /// # Example
217        ///
218        /// ```
219        /// use segment_buffer::AesGcmCipher;
220        ///
221        /// let key = [0u8; 32];
222        /// let _cipher = AesGcmCipher::from_slice(&key).unwrap();
223        /// ```
224        ///
225        /// # Errors
226        ///
227        /// Returns [`CipherError`] if the key length is not 32 bytes.
228        pub fn from_slice(key_bytes: &[u8]) -> Result<Self, CipherError> {
229            use aes_gcm::KeyInit;
230            let cipher = aes_gcm::Aes256Gcm::new_from_slice(key_bytes)
231                .map_err(|e| wrap("invalid AES-256 key", e))?;
232            Ok(Self { cipher })
233        }
234
235        /// Create a new cipher from a 32-byte AES-256 key (const-sized input).
236        ///
237        /// # Example
238        ///
239        /// ```
240        /// use segment_buffer::AesGcmCipher;
241        /// use segment_buffer::SegmentCipher;
242        ///
243        /// let cipher = AesGcmCipher::new(&[0u8; 32]);
244        /// let ciphertext = cipher.encrypt(b"hello").unwrap();
245        /// let plaintext = cipher.decrypt(&ciphertext).unwrap();
246        /// assert_eq!(plaintext, b"hello");
247        /// ```
248        ///
249        /// # Panics
250        ///
251        /// Never in practice — a 32-byte key is always valid for AES-256. The
252        /// internal `.expect()` is a defense-in-depth assertion against a future
253        /// logic bug (e.g. a key-type change); callers passing a correctly-sized
254        /// key will never hit it.
255        pub fn new(key_bytes: &[u8; 32]) -> Self {
256            use aes_gcm::KeyInit;
257            Self {
258                cipher: aes_gcm::Aes256Gcm::new_from_slice(key_bytes)
259                    .expect("32-byte key is always valid for AES-256"),
260            }
261        }
262    }
263
264    impl SegmentCipher for AesGcmCipher {
265        fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>, CipherError> {
266            use aes_gcm::aead::Aead;
267            use rand::Rng;
268
269            let mut nonce_bytes = [0u8; 12];
270            rand::rng().fill_bytes(&mut nonce_bytes);
271            let nonce = aes_gcm::Nonce::from(nonce_bytes);
272
273            let ciphertext = self
274                .cipher
275                .encrypt(&nonce, plaintext)
276                .map_err(|e| wrap("AES-GCM encryption failed", e))?;
277
278            let mut out = Vec::with_capacity(12 + ciphertext.len());
279            out.extend_from_slice(&nonce_bytes);
280            out.extend_from_slice(&ciphertext);
281            Ok(out)
282        }
283
284        fn decrypt(&self, ciphertext: &[u8]) -> Result<Vec<u8>, CipherError> {
285            use aes_gcm::aead::Aead;
286
287            if ciphertext.len() < 12 {
288                return Err(CipherError::msg("ciphertext too small for nonce prefix"));
289            }
290            let (nonce_bytes, encrypted) = ciphertext.split_at(12);
291            let nonce: [u8; 12] = nonce_bytes
292                .try_into()
293                .map_err(|_| CipherError::msg("invalid nonce length: expected 12 bytes"))?;
294            let nonce = aes_gcm::Nonce::from(nonce);
295
296            self.cipher
297                .decrypt(&nonce, encrypted)
298                .map_err(|e| wrap("AES-GCM decryption failed", e))
299        }
300    }
301
302    // -----------------------------------------------------------------------
303    // XChaCha20-Poly1305
304    // -----------------------------------------------------------------------
305
306    /// Nonce length for XChaCha20-Poly1305: 24 bytes. Public so callers can
307    /// reason about the on-disk payload shape without importing the AEAD crate.
308    const XCHACHA_NONCE_LEN: usize = 24;
309
310    /// XChaCha20-Poly1305 cipher with a random 24-byte nonce prepended to each
311    /// ciphertext.
312    ///
313    /// The on-disk payload format is:
314    /// `[24-byte nonce][ciphertext + 16-byte Poly1305 tag]`.
315    ///
316    /// # Why XChaCha20 over AES-GCM for new buffers
317    ///
318    /// - **No 2³²-message limit per key.** AES-GCM's 12-byte nonce collides
319    ///   after ~2³² messages under the same key (a collision breaks
320    ///   confidentiality). XChaCha20's 24-byte nonce makes random-nonce
321    ///   collision negligible well past 2⁴⁸ messages.
322    /// - **Constant-time on hosts without AES-NI.** ChaCha20 is constant-time
323    ///   in software; AES-GCM relies on hardware acceleration (AES-NI on
324    ///   x86, ARMv8 Crypto Extensions on aarch64) for performance and leaks
325    ///   timing on hosts without it (older CPUs, some embedded ARM).
326    ///
327    /// Legacy AES-GCM segments still decrypt through [`AesGcmCipher`]; the
328    /// two formats are byte-distinguishable only by which cipher the buffer
329    /// was opened with (no envelope marker for the cipher type today — see
330    /// the envelope v2 design doc for the migration path).
331    pub struct XChaCha20Poly1305Cipher {
332        cipher: chacha20poly1305::XChaCha20Poly1305,
333    }
334
335    impl XChaCha20Poly1305Cipher {
336        /// Create a new cipher from a 32-byte key.
337        ///
338        /// # Example
339        ///
340        /// ```
341        /// use segment_buffer::{SegmentCipher, XChaCha20Poly1305Cipher};
342        ///
343        /// let cipher = XChaCha20Poly1305Cipher::new(&[0u8; 32]);
344        /// let ciphertext = cipher.encrypt(b"hello").unwrap();
345        /// let plaintext = cipher.decrypt(&ciphertext).unwrap();
346        /// assert_eq!(plaintext, b"hello");
347        /// ```
348        ///
349        /// # Panics
350        ///
351        /// Never in practice — a 32-byte key is always valid for XChaCha20-Poly1305.
352        /// The internal `.expect()` is a defense-in-depth assertion against a
353        /// future logic bug (e.g. a key-type change); callers passing a
354        /// correctly-sized key will never hit it.
355        pub fn new(key_bytes: &[u8; 32]) -> Self {
356            use chacha20poly1305::KeyInit;
357            Self {
358                cipher: chacha20poly1305::XChaCha20Poly1305::new_from_slice(key_bytes)
359                    .expect("32-byte key is always valid for XChaCha20-Poly1305"),
360            }
361        }
362
363        /// Create a new cipher from a 32-byte slice. Falls back to
364        /// [`CipherError`] when the slice is not exactly 32 bytes.
365        ///
366        /// # Errors
367        ///
368        /// Returns [`CipherError`] if the key length is not 32 bytes.
369        pub fn from_slice(key_bytes: &[u8]) -> Result<Self, CipherError> {
370            use chacha20poly1305::KeyInit;
371            let cipher = chacha20poly1305::XChaCha20Poly1305::new_from_slice(key_bytes)
372                .map_err(|e| wrap("invalid XChaCha20 key", e))?;
373            Ok(Self { cipher })
374        }
375    }
376
377    impl SegmentCipher for XChaCha20Poly1305Cipher {
378        fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>, CipherError> {
379            use chacha20poly1305::aead::Aead;
380            use rand::Rng;
381
382            let mut nonce_bytes = [0u8; XCHACHA_NONCE_LEN];
383            rand::rng().fill_bytes(&mut nonce_bytes);
384            let nonce = chacha20poly1305::XNonce::from(nonce_bytes);
385
386            let ciphertext = self
387                .cipher
388                .encrypt(&nonce, plaintext)
389                .map_err(|e| wrap("XChaCha20 encryption failed", e))?;
390
391            let mut out = Vec::with_capacity(XCHACHA_NONCE_LEN + ciphertext.len());
392            out.extend_from_slice(&nonce_bytes);
393            out.extend_from_slice(&ciphertext);
394            Ok(out)
395        }
396
397        fn decrypt(&self, ciphertext: &[u8]) -> Result<Vec<u8>, CipherError> {
398            use chacha20poly1305::aead::Aead;
399
400            if ciphertext.len() < XCHACHA_NONCE_LEN {
401                return Err(CipherError::msg(
402                    "ciphertext too small for XChaCha20 nonce prefix (need 24 bytes)",
403                ));
404            }
405            let (nonce_bytes, encrypted) = ciphertext.split_at(XCHACHA_NONCE_LEN);
406            let nonce: [u8; XCHACHA_NONCE_LEN] = nonce_bytes
407                .try_into()
408                .map_err(|_| CipherError::msg("invalid nonce length: expected 24 bytes"))?;
409            let nonce = chacha20poly1305::XNonce::from(nonce);
410
411            self.cipher
412                .decrypt(&nonce, encrypted)
413                .map_err(|e| wrap("XChaCha20 decryption failed", e))
414        }
415    }
416}
417
418#[cfg(feature = "encryption")]
419pub use private::{AesGcmCipher, XChaCha20Poly1305Cipher};