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/// Helper trait that lets a `dyn Error + Send + Sync` trait object be upcast
16/// to `&dyn Error` without requiring Rust 1.86's trait-upcasting coercion.
17/// The crate's MSRV is 1.85; once it moves to 1.86+ this can be removed and
18/// `source()` can be a plain `self.source.as_deref()`.
19trait ErrorExt: fmt::Debug + std::error::Error {
20    /// Upcast this error to a `dyn std::error::Error` reference.
21    fn as_std_error(&self) -> &(dyn std::error::Error + 'static);
22}
23
24impl<T: std::error::Error + 'static> ErrorExt for T {
25    fn as_std_error(&self) -> &(dyn std::error::Error + 'static) {
26        self
27    }
28}
29
30/// Error returned by [`SegmentCipher`] implementations.
31///
32/// Deliberately minimal: the cipher operates on bytes, not files, so it has no
33/// path or sequence context to carry. The segment I/O layer enriches this into
34/// a [`crate::SegmentError::Cipher`] with the offending file's path.
35///
36/// Construct with [`CipherError::msg`] for a plain message, or
37/// [`CipherError::with_source`] when you want to preserve the underlying AEAD
38/// (or other) error type for `std::error::Error::source()` chaining. The
39/// fields are private so that adding context later is non-breaking.
40#[derive(Debug, Clone)]
41pub struct CipherError {
42    /// Human-readable description of what went wrong.
43    message: String,
44    /// Optional underlying cause (e.g. the AEAD crate's opaque error).
45    /// `Arc` (not `Box`) so [`CipherError`] stays [`Clone`]. Surfaced via
46    /// [`std::error::Error::source`].
47    source: Option<Arc<dyn ErrorExt + Send + Sync>>,
48}
49
50impl CipherError {
51    /// Construct a [`CipherError`] from anything displayable, with no
52    /// underlying cause.
53    ///
54    /// # Example
55    ///
56    /// ```
57    /// use segment_buffer::CipherError;
58    ///
59    /// let err = CipherError::msg("key not configured");
60    /// assert_eq!(err.to_string(), "key not configured");
61    /// assert!(std::error::Error::source(&err).is_none());
62    /// ```
63    pub fn msg(message: impl fmt::Display) -> Self {
64        Self {
65            message: message.to_string(),
66            source: None,
67        }
68    }
69
70    /// Construct a [`CipherError`] that preserves the underlying error so
71    /// operators can inspect it via [`std::error::Error::source`].
72    ///
73    /// Use this when wrapping a typed error from an AEAD implementation
74    /// (`aes_gcm::Error`, `chacha20poly1305::Error`, …) so the original
75    /// failure is not erased behind a `format!`.
76    ///
77    /// # Example
78    ///
79    /// ```
80    /// use segment_buffer::CipherError;
81    /// use std::fmt;
82    /// use std::error::Error;
83    ///
84    /// /// A tiny typed error an AEAD crate might expose.
85    /// #[derive(Debug)]
86    /// struct AeadError;
87    /// impl fmt::Display for AeadError {
88    ///     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
89    ///         f.write_str("tag mismatch")
90    ///     }
91    /// }
92    /// impl std::error::Error for AeadError {}
93    ///
94    /// let err = CipherError::with_source("AES-GCM decryption failed", AeadError);
95    /// assert_eq!(err.to_string(), "AES-GCM decryption failed");
96    /// // The underlying cause is preserved via `source()`:
97    /// let src = err.source().expect("source should be set by with_source");
98    /// assert_eq!(src.to_string(), "tag mismatch");
99    /// ```
100    pub fn with_source<E>(message: impl fmt::Display, source: E) -> Self
101    where
102        E: std::error::Error + Send + Sync + 'static,
103    {
104        Self {
105            message: message.to_string(),
106            source: Some(Arc::new(source)),
107        }
108    }
109}
110
111impl fmt::Display for CipherError {
112    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
113        f.write_str(&self.message)
114    }
115}
116
117impl std::error::Error for CipherError {
118    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
119        // `Arc<dyn ErrorExt + Send + Sync>` cannot be coerced to
120        // `&dyn Error` until trait-upcasting stabilises for our MSRV (1.86+);
121        // `ErrorExt::as_std_error` does the upcast explicitly.
122        self.source.as_ref().map(|s| s.as_std_error())
123    }
124}
125
126/// Encrypts and decrypts segment file payloads.
127///
128/// Implementations must be [`Send`] + [`Sync`] because the buffer is shared
129/// across threads via `Arc<SegmentBuffer>`.
130///
131/// The ciphertext format is implementation-defined but must be self-describing:
132/// [`decrypt`](Self::decrypt) must be able to recover the plaintext from the
133/// exact bytes returned by [`encrypt`](Self::encrypt) without external state.
134///
135/// # Naming
136///
137/// The trait is called `SegmentCipher`, not `SegmentAead`, even though the
138/// shipped implementation ([`AesGcmCipher`]) is an AEAD. This is deliberate:
139/// the trait contract is "any stateless self-describing encrypt/decrypt pair",
140/// which admits AEADs (recommended), HMAC-wrapped symmetric ciphers, or even
141/// custom schemes that combine encryption with a separate authenticator.
142/// Renaming to `SegmentAead` would narrow the contract to AEADs only — a
143/// constraint the trait does not actually enforce. Use an AEAD in practice;
144/// the trait stays general on purpose.
145///
146/// # Example
147///
148/// ```
149/// use segment_buffer::{CipherError, SegmentCipher};
150///
151/// struct Rot13;
152///
153/// impl SegmentCipher for Rot13 {
154///     fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>, CipherError> {
155///         Ok(plaintext.iter().map(|b| b.wrapping_add(13)).collect())
156///     }
157///     fn decrypt(&self, ciphertext: &[u8]) -> Result<Vec<u8>, CipherError> {
158///         Ok(ciphertext.iter().map(|b| b.wrapping_sub(13)).collect())
159///     }
160/// }
161/// ```
162pub trait SegmentCipher: Send + Sync {
163    /// Encrypt `plaintext`, returning self-describing ciphertext.
164    fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>, CipherError>;
165
166    /// Decrypt previously-produced ciphertext back to the original plaintext.
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        pub fn new(key_bytes: &[u8; 32]) -> Self {
249            use aes_gcm::KeyInit;
250            Self {
251                cipher: aes_gcm::Aes256Gcm::new_from_slice(key_bytes)
252                    .expect("32-byte key is always valid for AES-256"),
253            }
254        }
255    }
256
257    impl SegmentCipher for AesGcmCipher {
258        fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>, CipherError> {
259            use aes_gcm::aead::Aead;
260            use rand::RngCore;
261
262            let mut nonce_bytes = [0u8; 12];
263            rand::thread_rng().fill_bytes(&mut nonce_bytes);
264            let nonce = aes_gcm::Nonce::from_slice(&nonce_bytes);
265
266            let ciphertext = self
267                .cipher
268                .encrypt(nonce, plaintext)
269                .map_err(|e| wrap("AES-GCM encryption failed", e))?;
270
271            let mut out = Vec::with_capacity(12 + ciphertext.len());
272            out.extend_from_slice(&nonce_bytes);
273            out.extend_from_slice(&ciphertext);
274            Ok(out)
275        }
276
277        fn decrypt(&self, ciphertext: &[u8]) -> Result<Vec<u8>, CipherError> {
278            use aes_gcm::aead::Aead;
279
280            if ciphertext.len() < 12 {
281                return Err(CipherError::msg("ciphertext too small for nonce prefix"));
282            }
283            let (nonce_bytes, encrypted) = ciphertext.split_at(12);
284            let nonce = aes_gcm::Nonce::from_slice(nonce_bytes);
285
286            self.cipher
287                .decrypt(nonce, encrypted)
288                .map_err(|e| wrap("AES-GCM decryption failed", e))
289        }
290    }
291}
292
293#[cfg(feature = "encryption")]
294pub use private::AesGcmCipher;