Skip to main content

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}