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    pub fn msg(message: impl fmt::Display) -> Self {
54        Self {
55            message: message.to_string(),
56            source: None,
57        }
58    }
59
60    /// Construct a [`CipherError`] that preserves the underlying error so
61    /// operators can inspect it via [`std::error::Error::source`].
62    ///
63    /// Use this when wrapping a typed error from an AEAD implementation
64    /// (`aes_gcm::Error`, `chacha20poly1305::Error`, …) so the original
65    /// failure is not erased behind a `format!`.
66    pub fn with_source<E>(message: impl fmt::Display, source: E) -> Self
67    where
68        E: std::error::Error + Send + Sync + 'static,
69    {
70        Self {
71            message: message.to_string(),
72            source: Some(Arc::new(source)),
73        }
74    }
75}
76
77impl fmt::Display for CipherError {
78    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
79        f.write_str(&self.message)
80    }
81}
82
83impl std::error::Error for CipherError {
84    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
85        // `Arc<dyn ErrorExt + Send + Sync>` cannot be coerced to
86        // `&dyn Error` until trait-upcasting stabilises for our MSRV (1.86+);
87        // `ErrorExt::as_std_error` does the upcast explicitly.
88        self.source.as_ref().map(|s| s.as_std_error())
89    }
90}
91
92/// Encrypts and decrypts segment file payloads.
93///
94/// Implementations must be [`Send`] + [`Sync`] because the buffer is shared
95/// across threads via `Arc<SegmentBuffer>`.
96///
97/// The ciphertext format is implementation-defined but must be self-describing:
98/// [`decrypt`](Self::decrypt) must be able to recover the plaintext from the
99/// exact bytes returned by [`encrypt`](Self::encrypt) without external state.
100///
101/// # Example
102///
103/// ```
104/// use segment_buffer::{CipherError, SegmentCipher};
105///
106/// struct Rot13;
107///
108/// impl SegmentCipher for Rot13 {
109///     fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>, CipherError> {
110///         Ok(plaintext.iter().map(|b| b.wrapping_add(13)).collect())
111///     }
112///     fn decrypt(&self, ciphertext: &[u8]) -> Result<Vec<u8>, CipherError> {
113///         Ok(ciphertext.iter().map(|b| b.wrapping_sub(13)).collect())
114///     }
115/// }
116/// ```
117pub trait SegmentCipher: Send + Sync {
118    /// Encrypt `plaintext`, returning self-describing ciphertext.
119    fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>, CipherError>;
120
121    /// Decrypt previously-produced ciphertext back to the original plaintext.
122    fn decrypt(&self, ciphertext: &[u8]) -> Result<Vec<u8>, CipherError>;
123}
124
125// ---------------------------------------------------------------------------
126// AES-256-GCM implementation (behind the `encryption` feature)
127// ---------------------------------------------------------------------------
128
129#[cfg(feature = "encryption")]
130mod private {
131    use super::{CipherError, SegmentCipher};
132    use std::fmt;
133    use std::sync::Arc;
134
135    /// Wrapper that turns any `Display`able AEAD error (e.g. the opaque
136    /// `aes_gcm::Error`, which intentionally does not impl `std::error::Error`)
137    /// into something that does, so it can flow through
138    /// [`std::error::Error::source`] chains without losing the original
139    /// diagnostic message.
140    #[derive(Debug, Clone)]
141    struct AeadError(String);
142
143    impl fmt::Display for AeadError {
144        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
145            f.write_str(&self.0)
146        }
147    }
148
149    impl std::error::Error for AeadError {}
150
151    fn wrap<E: fmt::Display>(message: &'static str, e: E) -> CipherError {
152        CipherError {
153            message: message.to_string(),
154            source: Some(Arc::new(AeadError(e.to_string()))),
155        }
156    }
157
158    /// AES-256-GCM cipher with a random 12-byte nonce prepended to each ciphertext.
159    ///
160    /// The on-disk payload format is: `[12-byte nonce][ciphertext + 16-byte GCM tag]`.
161    /// This is byte-compatible with monitor365's `EncryptionKey` segment format,
162    /// so existing encrypted segments can be read without migration. (The segment
163    /// file envelope, if present, is stripped before the cipher sees the bytes.)
164    pub struct AesGcmCipher {
165        cipher: aes_gcm::Aes256Gcm,
166    }
167
168    impl AesGcmCipher {
169        /// Create a new cipher from a 32-byte AES-256 key.
170        ///
171        /// # Errors
172        ///
173        /// Returns [`CipherError`] if the key length is not 32 bytes.
174        pub fn from_slice(key_bytes: &[u8]) -> Result<Self, CipherError> {
175            use aes_gcm::KeyInit;
176            let cipher = aes_gcm::Aes256Gcm::new_from_slice(key_bytes)
177                .map_err(|e| wrap("invalid AES-256 key", e))?;
178            Ok(Self { cipher })
179        }
180
181        /// Create a new cipher from a 32-byte AES-256 key (const-sized input).
182        pub fn new(key_bytes: &[u8; 32]) -> Self {
183            use aes_gcm::KeyInit;
184            Self {
185                cipher: aes_gcm::Aes256Gcm::new_from_slice(key_bytes)
186                    .expect("32-byte key is always valid for AES-256"),
187            }
188        }
189    }
190
191    impl SegmentCipher for AesGcmCipher {
192        fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>, CipherError> {
193            use aes_gcm::aead::Aead;
194            use rand::RngCore;
195
196            let mut nonce_bytes = [0u8; 12];
197            rand::thread_rng().fill_bytes(&mut nonce_bytes);
198            let nonce = aes_gcm::Nonce::from_slice(&nonce_bytes);
199
200            let ciphertext = self
201                .cipher
202                .encrypt(nonce, plaintext)
203                .map_err(|e| wrap("AES-GCM encryption failed", e))?;
204
205            let mut out = Vec::with_capacity(12 + ciphertext.len());
206            out.extend_from_slice(&nonce_bytes);
207            out.extend_from_slice(&ciphertext);
208            Ok(out)
209        }
210
211        fn decrypt(&self, ciphertext: &[u8]) -> Result<Vec<u8>, CipherError> {
212            use aes_gcm::aead::Aead;
213
214            if ciphertext.len() < 12 {
215                return Err(CipherError::msg("ciphertext too small for nonce prefix"));
216            }
217            let (nonce_bytes, encrypted) = ciphertext.split_at(12);
218            let nonce = aes_gcm::Nonce::from_slice(nonce_bytes);
219
220            self.cipher
221                .decrypt(nonce, encrypted)
222                .map_err(|e| wrap("AES-GCM decryption failed", e))
223        }
224    }
225}
226
227#[cfg(feature = "encryption")]
228pub use private::AesGcmCipher;