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}