reddb_crypto/page_envelope.rs
1//! Canonical per-page encryption-at-rest envelope (#1053, ADR 0054).
2//!
3//! This is the single byte-format for an encrypted RedDB page. It
4//! consolidates two dormant, byte-incompatible predecessors:
5//!
6//! - **RDEP** (`reddb-server/crypto/page_encryption.rs`) — a
7//! self-describing frame carrying a `b"RDEP"` magic + version byte.
8//! *Retired.* Its genuinely-better pieces are carried forward here:
9//! the typed error enum, the OS-CSPRNG nonce source, and the
10//! hex/base64 key parser ([`crate::key`]).
11//! - **PageEncryptor** (`reddb-server/storage/encryption/page_encryptor.rs`)
12//! — the leaner magic-less frame, already wired into the dormant
13//! pager and already embedded as the page-0 header's `key_check`
14//! blob. *Its frame survives as the canonical layout below.*
15//!
16//! ## On-disk frame
17//!
18//! ```text
19//! [0..12] nonce (12 bytes, random per page, OS CSPRNG)
20//! [12..] ciphertext ‖ 16-byte AES-256-GCM tag
21//! ```
22//!
23//! Overhead is exactly [`PAGE_ENVELOPE_OVERHEAD`] = 28 bytes
24//! (nonce 12 + tag 16). Plaintext expands by precisely this much, so
25//! a fixed-size page slot stays fixed.
26//!
27//! ## Why no per-page magic/version
28//!
29//! Self-description for encryption-at-rest lives one level up, in the
30//! page-0 paged-encryption header (`reddb_file::PAGED_ENCRYPTION_MARKER`
31//! = `b"RDBE"` + `PagedEncryptionHeader`). That header is the
32//! file-level authority: it records *that* the database is encrypted,
33//! the salt, and a key-check blob. A database is encrypted under one
34//! scheme for its whole life, so a per-page magic+version would
35//! duplicate authority the page-0 header already holds — and the
36//! page-0 `key_check` slot is a fixed 60 bytes (= 32-byte plaintext +
37//! this 28-byte overhead), which a 33-byte RDEP frame would overflow.
38//! Keeping the per-page frame lean is therefore both an authority
39//! decision (ADR 0046 / 0054) and a hard layout constraint.
40//!
41//! ## Properties
42//!
43//! - **Random nonce per page** via the OS CSPRNG; collisions across
44//! `2^96` pages are astronomically unlikely. The API is stateless.
45//! - **AAD = `page_id` as `u32` LE** — binds the ciphertext to its
46//! page slot, so a peer-page swap fails the GCM tag check on
47//! decrypt. `u32` matches the engine's native page-id width (the
48//! pager addresses pages with `u32`; the page-0 key-check uses the
49//! sentinel `u32::MAX`). The retired RDEP envelope used `u64`,
50//! which was speculatively wide; binding to the real identifier
51//! width is the honest choice.
52
53use crate::aes_gcm::{aes256_gcm_decrypt, aes256_gcm_encrypt};
54use crate::os_random;
55use crate::params::{NONCE_SIZE, PAGE_ENVELOPE_OVERHEAD};
56
57/// Errors returned by the page-envelope surface. The caller (the
58/// pager) maps these to its own typed error.
59#[derive(Debug)]
60pub enum PageEnvelopeError {
61 /// Frame is shorter than [`PAGE_ENVELOPE_OVERHEAD`] — cannot even
62 /// contain a nonce + tag.
63 Truncated,
64 /// GCM tag check failed: wrong key, wrong `page_id` (AAD), or
65 /// tampering. These are functionally indistinguishable and all
66 /// fail closed.
67 KeyMismatch(String),
68 /// OS CSPRNG failed while drawing the nonce.
69 RandomFailure(String),
70}
71
72impl std::fmt::Display for PageEnvelopeError {
73 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74 match self {
75 Self::Truncated => f.write_str("encrypted page: truncated frame"),
76 Self::KeyMismatch(detail) => {
77 write!(f, "encrypted page: key mismatch or tampering ({detail})")
78 }
79 Self::RandomFailure(detail) => {
80 write!(f, "encrypted page: nonce generation failed ({detail})")
81 }
82 }
83 }
84}
85
86impl std::error::Error for PageEnvelopeError {}
87
88/// Encrypt `plaintext` for storage as page `page_id`. `page_id` is
89/// bound as AAD (`u32` LE), so swapping two pages on disk fails the
90/// tag check on decrypt.
91///
92/// Output layout: `nonce(12) ‖ ciphertext ‖ tag(16)`; length is
93/// `plaintext.len() + PAGE_ENVELOPE_OVERHEAD`.
94pub fn encrypt_page(
95 key: &[u8; 32],
96 page_id: u32,
97 plaintext: &[u8],
98) -> Result<Vec<u8>, PageEnvelopeError> {
99 let mut nonce = [0u8; NONCE_SIZE];
100 os_random::fill_bytes(&mut nonce).map_err(PageEnvelopeError::RandomFailure)?;
101 let aad = page_id.to_le_bytes();
102 let ciphertext = aes256_gcm_encrypt(key, &nonce, &aad, plaintext);
103
104 let mut out = Vec::with_capacity(PAGE_ENVELOPE_OVERHEAD + plaintext.len());
105 out.extend_from_slice(&nonce);
106 out.extend_from_slice(&ciphertext);
107 Ok(out)
108}
109
110/// Decrypt an envelope produced by [`encrypt_page`]. `page_id` MUST
111/// match the value passed at encrypt time — a mismatch surfaces as
112/// [`PageEnvelopeError::KeyMismatch`] (the GCM tag check failing),
113/// which is the correct signal: an attacker swapping pages is
114/// functionally indistinguishable from a wrong key.
115pub fn decrypt_page(
116 key: &[u8; 32],
117 page_id: u32,
118 frame: &[u8],
119) -> Result<Vec<u8>, PageEnvelopeError> {
120 if frame.len() < PAGE_ENVELOPE_OVERHEAD {
121 return Err(PageEnvelopeError::Truncated);
122 }
123 let mut nonce = [0u8; NONCE_SIZE];
124 nonce.copy_from_slice(&frame[..NONCE_SIZE]);
125 let aad = page_id.to_le_bytes();
126 aes256_gcm_decrypt(key, &nonce, &aad, &frame[NONCE_SIZE..])
127 .map_err(PageEnvelopeError::KeyMismatch)
128}
129
130#[cfg(test)]
131mod tests {
132 use super::*;
133
134 fn key() -> [u8; 32] {
135 let mut k = [0u8; 32];
136 for (i, b) in k.iter_mut().enumerate() {
137 *b = i as u8;
138 }
139 k
140 }
141
142 #[test]
143 fn round_trips_plaintext() {
144 let plaintext = b"page bytes that will be encrypted";
145 let frame = encrypt_page(&key(), 7, plaintext).unwrap();
146 assert_eq!(frame.len(), PAGE_ENVELOPE_OVERHEAD + plaintext.len());
147 let recovered = decrypt_page(&key(), 7, &frame).unwrap();
148 assert_eq!(recovered, plaintext);
149 }
150
151 #[test]
152 fn nonce_is_random_per_call() {
153 let plaintext = b"same payload, different nonce";
154 let f1 = encrypt_page(&key(), 1, plaintext).unwrap();
155 let f2 = encrypt_page(&key(), 1, plaintext).unwrap();
156 assert_ne!(f1, f2);
157 }
158
159 #[test]
160 fn page_id_binding_catches_swapped_pages() {
161 let plaintext = b"page 1 contents";
162 let frame = encrypt_page(&key(), 1, plaintext).unwrap();
163 let err = decrypt_page(&key(), 2, &frame).unwrap_err();
164 assert!(
165 matches!(err, PageEnvelopeError::KeyMismatch(_)),
166 "got {err:?}"
167 );
168 }
169
170 #[test]
171 fn wrong_key_fails_closed() {
172 let plaintext = b"sensitive";
173 let frame = encrypt_page(&key(), 5, plaintext).unwrap();
174 let mut wrong = key();
175 wrong[0] ^= 0xff;
176 let err = decrypt_page(&wrong, 5, &frame).unwrap_err();
177 assert!(matches!(err, PageEnvelopeError::KeyMismatch(_)));
178 }
179
180 #[test]
181 fn truncated_frame_is_typed() {
182 let frame = vec![0u8; PAGE_ENVELOPE_OVERHEAD - 1];
183 let err = decrypt_page(&key(), 0, &frame).unwrap_err();
184 assert!(matches!(err, PageEnvelopeError::Truncated));
185 }
186
187 #[test]
188 fn tampered_tag_fails() {
189 let frame = encrypt_page(&key(), 9, b"abc").unwrap();
190 let mut bad = frame.clone();
191 let last = bad.len() - 1;
192 bad[last] ^= 1;
193 assert!(decrypt_page(&key(), 9, &bad).is_err());
194 }
195
196 #[test]
197 fn error_display_is_specific_to_failure_class() {
198 assert_eq!(
199 PageEnvelopeError::Truncated.to_string(),
200 "encrypted page: truncated frame"
201 );
202 assert_eq!(
203 PageEnvelopeError::KeyMismatch("bad tag".to_string()).to_string(),
204 "encrypted page: key mismatch or tampering (bad tag)"
205 );
206 assert_eq!(
207 PageEnvelopeError::RandomFailure("no entropy".to_string()).to_string(),
208 "encrypted page: nonce generation failed (no entropy)"
209 );
210 }
211}