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;