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 /// Because the key length is fixed at compile time, construction is
238 /// infallible — unlike [`from_slice`](Self::from_slice), which must
239 /// validate runtime slice length.
240 ///
241 /// # Example
242 ///
243 /// ```
244 /// use segment_buffer::AesGcmCipher;
245 /// use segment_buffer::SegmentCipher;
246 ///
247 /// let cipher = AesGcmCipher::new(&[0u8; 32]);
248 /// let ciphertext = cipher.encrypt(b"hello").unwrap();
249 /// let plaintext = cipher.decrypt(&ciphertext).unwrap();
250 /// assert_eq!(plaintext, b"hello");
251 /// ```
252 #[must_use]
253 pub fn new(key_bytes: &[u8; 32]) -> Self {
254 use aes_gcm::KeyInit;
255 Self {
256 cipher: aes_gcm::Aes256Gcm::new(key_bytes.into()),
257 }
258 }
259 }
260
261 impl SegmentCipher for AesGcmCipher {
262 fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>, CipherError> {
263 use aes_gcm::aead::Aead;
264 use rand::Rng;
265
266 let mut nonce_bytes = [0u8; AES_GCM_NONCE_LEN];
267 rand::rng().fill_bytes(&mut nonce_bytes);
268 let nonce = aes_gcm::Nonce::from(nonce_bytes);
269
270 let ciphertext = self
271 .cipher
272 .encrypt(&nonce, plaintext)
273 .map_err(|e| wrap("AES-GCM encryption failed", e))?;
274
275 let mut out = Vec::with_capacity(AES_GCM_NONCE_LEN.saturating_add(ciphertext.len()));
276 out.extend_from_slice(&nonce_bytes);
277 out.extend_from_slice(&ciphertext);
278 Ok(out)
279 }
280
281 fn decrypt(&self, ciphertext: &[u8]) -> Result<Vec<u8>, CipherError> {
282 use aes_gcm::aead::Aead;
283
284 if ciphertext.len() < AES_GCM_NONCE_LEN {
285 return Err(CipherError::msg("ciphertext too small for nonce prefix"));
286 }
287 let (nonce_bytes, encrypted) = ciphertext.split_at(AES_GCM_NONCE_LEN);
288 let nonce: [u8; AES_GCM_NONCE_LEN] = nonce_bytes
289 .try_into()
290 .map_err(|_| CipherError::msg("invalid nonce length: expected 12 bytes"))?;
291 let nonce = aes_gcm::Nonce::from(nonce);
292
293 self.cipher
294 .decrypt(&nonce, encrypted)
295 .map_err(|e| wrap("AES-GCM decryption failed", e))
296 }
297 }
298
299 // -----------------------------------------------------------------------
300 // XChaCha20-Poly1305
301 // -----------------------------------------------------------------------
302
303 /// Nonce length for XChaCha20-Poly1305: 24 bytes. Public so callers can
304 /// reason about the on-disk payload shape without importing the AEAD crate.
305 const AES_GCM_NONCE_LEN: usize = 12;
306 const XCHACHA_NONCE_LEN: usize = 24;
307
308 /// XChaCha20-Poly1305 cipher with a random 24-byte nonce prepended to each
309 /// ciphertext.
310 ///
311 /// The on-disk payload format is:
312 /// `[24-byte nonce][ciphertext + 16-byte Poly1305 tag]`.
313 ///
314 /// # Why `XChaCha20` over AES-GCM for new buffers
315 ///
316 /// - **No 2³²-message limit per key.** AES-GCM's 12-byte nonce collides
317 /// after ~2³² messages under the same key (a collision breaks
318 /// confidentiality). `XChaCha20`'s 24-byte nonce makes random-nonce
319 /// collision negligible well past 2⁴⁸ messages.
320 /// - **Constant-time on hosts without AES-NI.** `ChaCha20` is constant-time
321 /// in software; AES-GCM relies on hardware acceleration (AES-NI on
322 /// `x86`, `ARMv8` Crypto Extensions on aarch64) for performance and leaks
323 /// timing on hosts without it (older CPUs, some embedded ARM).
324 ///
325 /// Legacy AES-GCM segments still decrypt through [`AesGcmCipher`]; the
326 /// two formats are byte-distinguishable only by which cipher the buffer
327 /// was opened with (no envelope marker for the cipher type today — see
328 /// the envelope v2 design doc for the migration path).
329 pub struct XChaCha20Poly1305Cipher {
330 cipher: chacha20poly1305::XChaCha20Poly1305,
331 }
332
333 impl XChaCha20Poly1305Cipher {
334 /// Create a new cipher from a 32-byte key.
335 ///
336 /// # Example
337 ///
338 /// ```
339 /// use segment_buffer::{SegmentCipher, XChaCha20Poly1305Cipher};
340 ///
341 /// let cipher = XChaCha20Poly1305Cipher::new(&[0u8; 32]);
342 /// let ciphertext = cipher.encrypt(b"hello").unwrap();
343 /// let plaintext = cipher.decrypt(&ciphertext).unwrap();
344 /// assert_eq!(plaintext, b"hello");
345 /// ```
346 ///
347 /// Because the key length is fixed at compile time, construction is
348 /// infallible — unlike [`from_slice`](Self::from_slice), which must
349 /// validate runtime slice length.
350 #[must_use]
351 pub fn new(key_bytes: &[u8; 32]) -> Self {
352 use chacha20poly1305::KeyInit;
353 Self {
354 cipher: chacha20poly1305::XChaCha20Poly1305::new(key_bytes.into()),
355 }
356 }
357
358 /// Create a new cipher from a 32-byte slice. Falls back to
359 /// [`CipherError`] when the slice is not exactly 32 bytes.
360 ///
361 /// # Errors
362 ///
363 /// Returns [`CipherError`] if the key length is not 32 bytes.
364 pub fn from_slice(key_bytes: &[u8]) -> Result<Self, CipherError> {
365 use chacha20poly1305::KeyInit;
366 let cipher = chacha20poly1305::XChaCha20Poly1305::new_from_slice(key_bytes)
367 .map_err(|e| wrap("invalid XChaCha20 key", e))?;
368 Ok(Self { cipher })
369 }
370 }
371
372 impl SegmentCipher for XChaCha20Poly1305Cipher {
373 fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>, CipherError> {
374 use chacha20poly1305::aead::Aead;
375 use rand::Rng;
376
377 let mut nonce_bytes = [0u8; XCHACHA_NONCE_LEN];
378 rand::rng().fill_bytes(&mut nonce_bytes);
379 let nonce = chacha20poly1305::XNonce::from(nonce_bytes);
380
381 let ciphertext = self
382 .cipher
383 .encrypt(&nonce, plaintext)
384 .map_err(|e| wrap("XChaCha20 encryption failed", e))?;
385
386 let mut out = Vec::with_capacity(XCHACHA_NONCE_LEN.saturating_add(ciphertext.len()));
387 out.extend_from_slice(&nonce_bytes);
388 out.extend_from_slice(&ciphertext);
389 Ok(out)
390 }
391
392 fn decrypt(&self, ciphertext: &[u8]) -> Result<Vec<u8>, CipherError> {
393 use chacha20poly1305::aead::Aead;
394
395 if ciphertext.len() < XCHACHA_NONCE_LEN {
396 return Err(CipherError::msg(
397 "ciphertext too small for XChaCha20 nonce prefix (need 24 bytes)",
398 ));
399 }
400 let (nonce_bytes, encrypted) = ciphertext.split_at(XCHACHA_NONCE_LEN);
401 let nonce: [u8; XCHACHA_NONCE_LEN] = nonce_bytes
402 .try_into()
403 .map_err(|_| CipherError::msg("invalid nonce length: expected 24 bytes"))?;
404 let nonce = chacha20poly1305::XNonce::from(nonce);
405
406 self.cipher
407 .decrypt(&nonce, encrypted)
408 .map_err(|e| wrap("XChaCha20 decryption failed", e))
409 }
410 }
411}
412
413#[cfg(feature = "encryption")]
414pub use private::{AesGcmCipher, XChaCha20Poly1305Cipher};