Skip to main content

sparrowdb_storage/
encryption.rs

1//! At-rest page encryption using XChaCha20-Poly1305.
2//!
3//! # Physical page layout on disk
4//!
5//! ```text
6//! ┌─────────────────────────────────────────────────────────────────────┐
7//! │  nonce (24 bytes)  │  ciphertext + auth tag (page_size + 16 bytes)  │
8//! └─────────────────────────────────────────────────────────────────────┘
9//!   total on disk: page_size + 40 bytes  ("encrypted stride")
10//! ```
11//!
12//! The nonce is generated fresh from the OS CSPRNG on every write.
13//! It is stored inline in the page so decryption is self-contained.
14//!
15//! The `page_id` is passed as AEAD Associated Authenticated Data (AAD) on
16//! both encrypt and decrypt.  This cryptographically binds each ciphertext
17//! to its logical page location: swapping the encrypted blob of page A into
18//! slot B will cause the AEAD tag to fail, defeating page-swap / relocation
19//! attacks without any additional nonce comparison.
20//!
21//! # Passthrough mode
22//!
23//! When constructed with [`EncryptionContext::none`], all operations are
24//! identity functions — plaintext in, plaintext out.
25
26use chacha20poly1305::{
27    aead::{Aead, KeyInit, Payload},
28    XChaCha20Poly1305, XNonce,
29};
30use rand::{rngs::OsRng, RngCore};
31use sparrowdb_common::{Error, Result};
32
33/// Encryption context — holds the 32-byte master key for an open database.
34///
35/// When the key is `None` the context operates in passthrough mode and pages
36/// are read/written without any encryption.
37pub struct EncryptionContext {
38    cipher: Option<XChaCha20Poly1305>,
39}
40
41impl EncryptionContext {
42    /// Create a context that performs no encryption (passthrough mode).
43    pub fn none() -> Self {
44        Self { cipher: None }
45    }
46
47    /// Create a context that encrypts all pages with the given 32-byte key.
48    pub fn with_key(key: [u8; 32]) -> Self {
49        let cipher = XChaCha20Poly1305::new(&key.into());
50        Self {
51            cipher: Some(cipher),
52        }
53    }
54
55    /// Returns `true` if this context has an encryption key (non-passthrough).
56    pub fn is_encrypted(&self) -> bool {
57        self.cipher.is_some()
58    }
59
60    /// Encrypt a WAL record payload.
61    ///
62    /// `lsn` is used as the AEAD AAD, binding the ciphertext to its log position.
63    ///
64    /// Output layout: `[nonce: 24 bytes][ciphertext+tag: plaintext.len()+16 bytes]`
65    ///
66    /// In passthrough mode the plaintext is returned as-is.
67    ///
68    /// # Errors
69    /// Returns [`Error::Corruption`] if the underlying AEAD encrypt fails.
70    pub fn encrypt_wal_payload(&self, lsn: u64, plaintext: &[u8]) -> Result<Vec<u8>> {
71        let cipher = match &self.cipher {
72            None => return Ok(plaintext.to_vec()),
73            Some(c) => c,
74        };
75
76        let mut nonce_bytes = [0u8; 24];
77        OsRng.fill_bytes(&mut nonce_bytes);
78        let nonce = *XNonce::from_slice(&nonce_bytes);
79        let aad = lsn.to_le_bytes();
80
81        let ciphertext = cipher
82            .encrypt(
83                &nonce,
84                Payload {
85                    msg: plaintext,
86                    aad: &aad,
87                },
88            )
89            .map_err(|_| Error::Corruption("XChaCha20-Poly1305 WAL encrypt failed".into()))?;
90
91        let mut output = Vec::with_capacity(24 + ciphertext.len());
92        output.extend_from_slice(nonce.as_slice());
93        output.extend_from_slice(&ciphertext);
94        Ok(output)
95    }
96
97    /// Decrypt a WAL record payload encrypted with [`encrypt_wal_payload`].
98    ///
99    /// `lsn` is used as AEAD AAD — must match the value used during encryption.
100    ///
101    /// In passthrough mode the data is returned as-is.
102    ///
103    /// # Errors
104    /// - [`Error::EncryptionAuthFailed`] — wrong key or the LSN does not match.
105    /// - [`Error::InvalidArgument`] — `encrypted` is shorter than 40 bytes.
106    pub fn decrypt_wal_payload(&self, lsn: u64, encrypted: &[u8]) -> Result<Vec<u8>> {
107        let cipher = match &self.cipher {
108            None => return Ok(encrypted.to_vec()),
109            Some(c) => c,
110        };
111
112        if encrypted.len() < 40 {
113            return Err(Error::InvalidArgument(format!(
114                "encrypted WAL payload is {} bytes; minimum is 40 (24-byte nonce + 16-byte tag)",
115                encrypted.len()
116            )));
117        }
118
119        let nonce = XNonce::from_slice(&encrypted[..24]);
120        let aad = lsn.to_le_bytes();
121        let plaintext = cipher
122            .decrypt(
123                nonce,
124                Payload {
125                    msg: &encrypted[24..],
126                    aad: &aad,
127                },
128            )
129            .map_err(|_| Error::EncryptionAuthFailed)?;
130
131        Ok(plaintext)
132    }
133
134    /// Encrypt a plaintext page and return the on-disk representation.
135    ///
136    /// A fresh 24-byte nonce is generated from the OS CSPRNG on every call.
137    /// `page_id` is passed as AEAD AAD so the ciphertext is cryptographically
138    /// bound to its logical page location.
139    ///
140    /// Output layout: `[nonce: 24 bytes][ciphertext+tag: plaintext.len()+16 bytes]`
141    /// Total length: `plaintext.len() + 40`.
142    ///
143    /// In passthrough mode the plaintext is returned as-is (no overhead bytes).
144    ///
145    /// # Errors
146    /// Returns [`Error::Corruption`] if the underlying AEAD encrypt fails
147    /// (extremely unlikely — only possible if plaintext is too large for the
148    /// AEAD to handle, which the chacha20poly1305 crate does not bound in
149    /// normal usage).
150    pub fn encrypt_page(&self, page_id: u64, plaintext: &[u8]) -> Result<Vec<u8>> {
151        let cipher = match &self.cipher {
152            None => return Ok(plaintext.to_vec()),
153            Some(c) => c,
154        };
155
156        // Generate a fresh random nonce for every write.
157        let mut nonce_bytes = [0u8; 24];
158        OsRng.fill_bytes(&mut nonce_bytes);
159        let nonce = *XNonce::from_slice(&nonce_bytes);
160
161        let aad = page_id.to_le_bytes();
162        let ciphertext = cipher
163            .encrypt(
164                &nonce,
165                Payload {
166                    msg: plaintext,
167                    aad: &aad,
168                },
169            )
170            .map_err(|_| Error::Corruption("XChaCha20-Poly1305 encrypt failed".into()))?;
171
172        // Prepend the nonce so the on-disk page is self-describing.
173        let mut output = Vec::with_capacity(24 + ciphertext.len());
174        output.extend_from_slice(nonce.as_slice());
175        output.extend_from_slice(&ciphertext);
176        Ok(output)
177    }
178
179    /// Decrypt an on-disk page back to plaintext.
180    ///
181    /// Expects `encrypted` to be at least 40 bytes (`24` nonce + `16` tag).
182    ///
183    /// `page_id` is passed as AEAD AAD — the AEAD authentication tag will
184    /// reject ciphertexts encrypted under a different `page_id`, defeating
185    /// page-swap / relocation attacks.
186    ///
187    /// In passthrough mode the data is returned as-is.
188    ///
189    /// # Errors
190    /// - [`Error::EncryptionAuthFailed`] — the AEAD authentication tag was
191    ///   rejected (wrong key, corrupted data, or page-swap attack detected).
192    /// - [`Error::InvalidArgument`] — `encrypted` is shorter than 40 bytes.
193    pub fn decrypt_page(&self, page_id: u64, encrypted: &[u8]) -> Result<Vec<u8>> {
194        let cipher = match &self.cipher {
195            None => return Ok(encrypted.to_vec()),
196            Some(c) => c,
197        };
198
199        if encrypted.len() < 40 {
200            return Err(Error::InvalidArgument(format!(
201                "encrypted page is {} bytes; minimum is 40 (24-byte nonce + 16-byte tag)",
202                encrypted.len()
203            )));
204        }
205
206        let nonce = XNonce::from_slice(&encrypted[..24]);
207        let aad = page_id.to_le_bytes();
208        let plaintext = cipher
209            .decrypt(
210                nonce,
211                Payload {
212                    msg: &encrypted[24..],
213                    aad: &aad,
214                },
215            )
216            .map_err(|_| Error::EncryptionAuthFailed)?;
217
218        Ok(plaintext)
219    }
220}
221
222#[cfg(test)]
223mod unit_tests {
224    use super::*;
225
226    #[test]
227    fn nonces_are_random() {
228        // Two encryptions of the same page must produce different nonces.
229        let ctx = EncryptionContext::with_key([0x01; 32]);
230        let pt = vec![0u8; 32];
231        let ct0 = ctx.encrypt_page(0, &pt).unwrap();
232        let ct1 = ctx.encrypt_page(0, &pt).unwrap();
233        // The first 24 bytes are the nonces — they must differ.
234        assert_ne!(
235            &ct0[..24],
236            &ct1[..24],
237            "nonces must be random, not deterministic"
238        );
239    }
240
241    #[test]
242    fn encrypt_output_length() {
243        let ctx = EncryptionContext::with_key([0x01; 32]);
244        let pt = vec![0u8; 512];
245        let ct = ctx.encrypt_page(0, &pt).unwrap();
246        assert_eq!(ct.len(), 512 + 40);
247    }
248
249    #[test]
250    fn passthrough_is_identity() {
251        let ctx = EncryptionContext::none();
252        let data = vec![0xFFu8; 256];
253        assert_eq!(ctx.encrypt_page(5, &data).unwrap(), data);
254        assert_eq!(ctx.decrypt_page(5, &data).unwrap(), data);
255    }
256
257    #[test]
258    fn too_short_ciphertext_is_rejected() {
259        let ctx = EncryptionContext::with_key([0x01; 32]);
260        let result = ctx.decrypt_page(0, &[0u8; 39]);
261        assert!(matches!(result, Err(Error::InvalidArgument(_))));
262    }
263}