Skip to main content

chronix_engine/segment/
field_encryption.rs

1//! Field-level encryption for columnar segment blocks.
2//!
3//! Provides per-column AES-256-GCM encryption with per-block random nonces.
4//! Each encrypted block is stored as `[12-byte nonce][ciphertext + 16-byte tag]`.
5//!
6//! # Security Properties
7//!
8//! - **Per-block random nonces**: every column block gets a fresh 12-byte nonce
9//!   from `OsRng`, preventing nonce reuse even if the same data is written twice.
10//! - **Authenticated encryption**: AES-256-GCM provides both confidentiality
11//!   and integrity. Tampered ciphertext is detected and rejected.
12//! - **Stats suppression**: encrypted columns have their min/max/sum/distinct
13//!   statistics zeroed to prevent information leakage via zone maps.
14//! - **Bloom filter suppression**: tag columns with encryption skip bloom
15//!   filter construction.
16//!
17//! # Performance Tradeoff: Statistics Suppression
18//!
19//! Encrypting a column **suppresses all zone-map statistics** (min, max, sum,
20//! distinct count). This is a deliberate security measure — publishing
21//! plaintext min/max values would leak information about the encrypted data.
22//!
23//! **Consequence:** Predicate pushdown cannot prune row groups based on
24//! encrypted columns. Queries that filter primarily on an encrypted column
25//! will read and decrypt every segment instead of skipping non-matching
26//! row groups. This can cause **significant read amplification** when most
27//! data would otherwise be pruned (e.g., 10× for a 90%-selective filter).
28//!
29//! **Mitigation strategies:**
30//!
31//! 1. **Filter on non-encrypted columns first.** Time-range and plaintext
32//!    tag filters still benefit from zone-map pruning and bloom filters.
33//!    Apply encrypted-column predicates as a post-decryption filter.
34//!
35//! 2. **Encrypt only sensitive fields.** Keep frequently-filtered columns
36//!    (e.g., `host`, `region`) in plaintext and encrypt only payload
37//!    fields (e.g., `patient_id`, `credit_card`).
38//!
39//! 3. **Partition by sensitive dimension.** If you need to filter on an
40//!    encrypted tag, consider using it as a partition key so that each
41//!    segment contains exactly one distinct value.
42//!
43//! For a full discussion see the [encryption section](../../docs/security.md)
44//! in the user guide.
45//!
46//! # Key Management
47//!
48//! Keys are identified by string IDs, enabling key rotation. The writer
49//! receives a mapping of column names to [`FieldEncryptionKey`] values.
50//! The reader must provide a [`FieldKeyProvider`] that resolves key IDs
51//! to the actual 256-bit key material.
52//!
53//! # Format
54//!
55//! ```text
56//! Encrypted block layout:
57//!   [nonce: 12 bytes][ciphertext: N bytes][auth_tag: 16 bytes]
58//!
59//! Where N = len(encoded_and_possibly_compressed_block)
60//! ```
61
62use crate::segment::error::{Result, SegmentError};
63
64use aes_gcm::aead::{Aead, KeyInit};
65use aes_gcm::{Aes256Gcm, Nonce};
66
67/// AES-256-GCM nonce size (96 bits).
68pub(crate) const NONCE_SIZE: usize = 12;
69
70/// Fill `dest` from the operating system's random source.
71///
72/// A GCM nonce must never repeat under one key, so there is no fallback worth
73/// having: if the kernel has no entropy source this panics rather than
74/// producing a nonce that could collide.
75fn fill_random(dest: &mut [u8]) {
76    use rand::TryRng;
77    rand::rngs::SysRng
78        .try_fill_bytes(dest)
79        .expect("the operating system random source must be available");
80}
81
82/// AES-256-GCM authentication tag size (128 bits).
83pub(crate) const TAG_SIZE: usize = 16;
84
85/// A 256-bit encryption key with its ID for key rotation tracking.
86#[derive(Clone)]
87pub struct FieldEncryptionKey {
88    /// Opaque key identifier for rotation / lookup.
89    pub key_id: String,
90    /// 32-byte AES-256 key material.
91    key: [u8; 32],
92}
93
94impl FieldEncryptionKey {
95    /// Create a new field encryption key.
96    ///
97    /// # Panics
98    ///
99    /// Panics if `key_bytes` length is not exactly 32.
100    #[must_use]
101    pub fn new(key_id: impl Into<String>, key_bytes: [u8; 32]) -> Self {
102        Self {
103            key_id: key_id.into(),
104            key: key_bytes,
105        }
106    }
107
108    /// Access the raw key bytes.
109    #[must_use]
110    pub fn key_bytes(&self) -> &[u8; 32] {
111        &self.key
112    }
113}
114
115impl std::fmt::Debug for FieldEncryptionKey {
116    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
117        f.debug_struct("FieldEncryptionKey")
118            .field("key_id", &self.key_id)
119            .field("key", &"[REDACTED]")
120            .finish()
121    }
122}
123
124/// Configuration for field-level encryption in the segment writer.
125///
126/// Maps column names to encryption keys. Only columns listed here are
127/// encrypted; all other columns remain in plaintext.
128#[derive(Debug, Clone, Default)]
129pub struct FieldEncryptionConfig {
130    /// Column name → encryption key mapping.
131    pub columns: std::collections::HashMap<String, FieldEncryptionKey>,
132}
133
134impl FieldEncryptionConfig {
135    /// Create an empty config (no columns encrypted).
136    #[must_use]
137    pub fn new() -> Self {
138        Self::default()
139    }
140
141    /// Add a column to encrypt.
142    pub fn encrypt_column(&mut self, column_name: impl Into<String>, key: FieldEncryptionKey) {
143        self.columns.insert(column_name.into(), key);
144    }
145
146    /// Check whether a column should be encrypted.
147    #[must_use]
148    pub fn is_encrypted(&self, column_name: &str) -> bool {
149        self.columns.contains_key(column_name)
150    }
151
152    /// Get the encryption key for a column, if any.
153    #[must_use]
154    pub fn key_for(&self, column_name: &str) -> Option<&FieldEncryptionKey> {
155        self.columns.get(column_name)
156    }
157}
158
159/// Trait for resolving encryption key IDs to key material during reads.
160pub trait FieldKeyProvider: Send + Sync {
161    /// Look up an encryption key by its ID.
162    ///
163    /// Returns the 32-byte AES-256 key material, or `None` if the key ID
164    /// is unknown (e.g. after key deletion or misconfiguration).
165    fn get_key(&self, key_id: &str) -> Option<[u8; 32]>;
166}
167
168/// Simple in-memory key provider for testing and embedded use.
169#[derive(Debug, Clone, Default)]
170pub struct StaticKeyProvider {
171    keys: std::collections::HashMap<String, [u8; 32]>,
172}
173
174impl StaticKeyProvider {
175    /// Create an empty provider.
176    #[must_use]
177    pub fn new() -> Self {
178        Self::default()
179    }
180
181    /// Register a key.
182    pub fn add_key(&mut self, key_id: impl Into<String>, key_bytes: [u8; 32]) {
183        self.keys.insert(key_id.into(), key_bytes);
184    }
185}
186
187impl FieldKeyProvider for StaticKeyProvider {
188    fn get_key(&self, key_id: &str) -> Option<[u8; 32]> {
189        self.keys.get(key_id).copied()
190    }
191}
192
193/// Where an encrypted block belongs, bound into its authentication tag.
194///
195/// AES-GCM proves a ciphertext was written by someone holding the key. It
196/// says nothing about **which** ciphertext this is, so without associated
197/// data any encrypted block decrypts correctly in any other block's place:
198/// one column reads as another, or one segment's values as another's, by
199/// swapping bytes between two files that share a key. Nothing detects it,
200/// because the tag verifies.
201///
202/// Binding the column and the segment makes a moved block fail to
203/// authenticate.
204///
205/// The segment is identified by its header's creation timestamp rather than
206/// by a catalog ID: the catalog assigns an ID only after the file is
207/// written, whereas the header is written before the first block, so it is
208/// the one segment-unique value both the writer and the reader hold at the
209/// moment they need it. Compaction writes a new segment with its own
210/// timestamp and re-encrypts, so a compacted block is bound to where it
211/// actually ended up.
212#[derive(Debug, Clone, Copy)]
213pub(crate) struct BlockContext<'a> {
214    /// Column name.
215    pub column: &'a str,
216    /// The `created_at` of the segment holding this block.
217    pub segment_created_at: i64,
218}
219
220impl BlockContext<'_> {
221    /// The associated-data bytes. Length-prefixed, so that a measurement
222    /// and column pair cannot be re-split to produce the same bytes.
223    fn associated_data(self) -> Vec<u8> {
224        let mut aad = Vec::with_capacity(self.column.len() + 32);
225        aad.extend_from_slice(b"chronix-field-v1");
226        aad.extend_from_slice(&(self.column.len() as u32).to_le_bytes());
227        aad.extend_from_slice(self.column.as_bytes());
228        aad.extend_from_slice(&self.segment_created_at.to_le_bytes());
229        aad
230    }
231}
232
233/// Encrypt a column block using AES-256-GCM, bound to where it belongs.
234///
235/// Returns `[nonce (12)][ciphertext + tag]`.
236pub(crate) fn encrypt_block(
237    plaintext: &[u8],
238    key: &[u8; 32],
239    context: BlockContext<'_>,
240) -> Result<Vec<u8>> {
241    let cipher = Aes256Gcm::new_from_slice(key).map_err(|e| SegmentError::CorruptFile {
242        detail: format!("invalid encryption key: {e}"),
243    })?;
244
245    // `aead` no longer re-exports an OS generator, and `generate_nonce` went
246    // with it; the 96 bits come from the same source either way.
247    let mut nonce_bytes = [0u8; NONCE_SIZE];
248    fill_random(&mut nonce_bytes);
249    let nonce = Nonce::try_from(&nonce_bytes[..]).map_err(|_| SegmentError::CorruptFile {
250        detail: "nonce must be 12 bytes".into(),
251    })?;
252    let aad = context.associated_data();
253    let ciphertext = cipher
254        .encrypt(
255            &nonce,
256            aes_gcm::aead::Payload {
257                msg: plaintext,
258                aad: &aad,
259            },
260        )
261        .map_err(|e| SegmentError::CorruptFile {
262            detail: format!("field encryption failed: {e}"),
263        })?;
264
265    let mut result = Vec::with_capacity(NONCE_SIZE + ciphertext.len());
266    result.extend_from_slice(&nonce);
267    result.extend_from_slice(&ciphertext);
268    Ok(result)
269}
270
271/// Decrypt a column block using AES-256-GCM.
272///
273/// Expects `[nonce (12)][ciphertext + tag]`, and the same context the
274/// block was encrypted under. A block moved to another column or another
275/// segment fails to authenticate.
276pub(crate) fn decrypt_block(
277    encrypted: &[u8],
278    key: &[u8; 32],
279    context: BlockContext<'_>,
280) -> Result<Vec<u8>> {
281    if encrypted.len() < NONCE_SIZE + TAG_SIZE {
282        return Err(SegmentError::CorruptFile {
283            detail: format!(
284                "encrypted block too short: {} bytes (need at least {})",
285                encrypted.len(),
286                NONCE_SIZE + TAG_SIZE,
287            ),
288        });
289    }
290
291    let cipher = Aes256Gcm::new_from_slice(key).map_err(|e| SegmentError::CorruptFile {
292        detail: format!("invalid decryption key: {e}"),
293    })?;
294
295    let nonce =
296        Nonce::try_from(&encrypted[..NONCE_SIZE]).map_err(|_| SegmentError::CorruptFile {
297            detail: "nonce must be 12 bytes".into(),
298        })?;
299    let ciphertext = &encrypted[NONCE_SIZE..];
300    let aad = context.associated_data();
301
302    cipher
303        .decrypt(
304            &nonce,
305            aes_gcm::aead::Payload {
306                msg: ciphertext,
307                aad: &aad,
308            },
309        )
310        .map_err(|e| SegmentError::CorruptFile {
311            detail: format!(
312                "field decryption failed (wrong key, moved block, or tampered data): {e}"
313            ),
314        })
315}
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320
321    /// A fixed context for the round-trip tests.
322    fn ctx() -> BlockContext<'static> {
323        BlockContext {
324            column: "value",
325            segment_created_at: 1_700_000_000_000_000_000,
326        }
327    }
328
329    fn test_key() -> [u8; 32] {
330        let mut key = [0u8; 32];
331        for (i, b) in key.iter_mut().enumerate() {
332            *b = i as u8;
333        }
334        key
335    }
336
337    #[test]
338    fn encrypt_decrypt_roundtrip() {
339        let key = test_key();
340        let plaintext = b"Hello, field-level encryption!";
341        let encrypted = encrypt_block(plaintext, &key, ctx()).unwrap();
342        assert_ne!(encrypted, plaintext);
343        assert!(encrypted.len() > plaintext.len()); // nonce + tag overhead
344        let decrypted = decrypt_block(&encrypted, &key, ctx()).unwrap();
345        assert_eq!(decrypted, plaintext);
346    }
347
348    #[test]
349    fn different_nonces_produce_different_ciphertext() {
350        let key = test_key();
351        let plaintext = b"same data";
352        let enc1 = encrypt_block(plaintext, &key, ctx()).unwrap();
353        let enc2 = encrypt_block(plaintext, &key, ctx()).unwrap();
354        // Nonces should differ (with overwhelming probability)
355        assert_ne!(&enc1[..NONCE_SIZE], &enc2[..NONCE_SIZE]);
356    }
357
358    #[test]
359    fn wrong_key_fails_decryption() {
360        let key1 = test_key();
361        let mut key2 = test_key();
362        key2[0] ^= 0xFF; // flip a bit
363        let encrypted = encrypt_block(b"secret", &key1, ctx()).unwrap();
364        let result = decrypt_block(&encrypted, &key2, ctx());
365        assert!(result.is_err());
366    }
367
368    #[test]
369    fn tampered_ciphertext_fails() {
370        let key = test_key();
371        let mut encrypted = encrypt_block(b"important data", &key, ctx()).unwrap();
372        // Flip a byte in the ciphertext
373        let last = encrypted.len() - 1;
374        encrypted[last] ^= 0x01;
375        let result = decrypt_block(&encrypted, &key, ctx());
376        assert!(result.is_err());
377    }
378
379    #[test]
380    fn too_short_encrypted_block_fails() {
381        let key = test_key();
382        let result = decrypt_block(&[0u8; 10], &key, ctx());
383        assert!(result.is_err());
384    }
385
386    #[test]
387    fn field_encryption_config_basic() {
388        let mut config = FieldEncryptionConfig::new();
389        assert!(!config.is_encrypted("temperature"));
390
391        let key = FieldEncryptionKey::new("key-1", test_key());
392        config.encrypt_column("temperature", key);
393
394        assert!(config.is_encrypted("temperature"));
395        assert!(!config.is_encrypted("humidity"));
396        assert_eq!(config.key_for("temperature").unwrap().key_id, "key-1");
397    }
398
399    #[test]
400    fn static_key_provider_lookup() {
401        let mut provider = StaticKeyProvider::new();
402        let key = test_key();
403        provider.add_key("key-1", key);
404
405        assert_eq!(provider.get_key("key-1"), Some(key));
406        assert_eq!(provider.get_key("key-2"), None);
407    }
408
409    #[test]
410    fn field_encryption_key_debug_redacts() {
411        let key = FieldEncryptionKey::new("key-1", test_key());
412        let debug = format!("{key:?}");
413        assert!(debug.contains("REDACTED"));
414        assert!(!debug.contains(&format!("{:?}", test_key())));
415    }
416
417    #[test]
418    fn encrypt_empty_block() {
419        let key = test_key();
420        let encrypted = encrypt_block(b"", &key, ctx()).unwrap();
421        assert_eq!(encrypted.len(), NONCE_SIZE + TAG_SIZE); // nonce + tag, no ciphertext
422        let decrypted = decrypt_block(&encrypted, &key, ctx()).unwrap();
423        assert!(decrypted.is_empty());
424    }
425
426    #[test]
427    fn encrypt_large_block() {
428        let key = test_key();
429        let plaintext = vec![0xABu8; 1024 * 1024]; // 1 MiB
430        let encrypted = encrypt_block(&plaintext, &key, ctx()).unwrap();
431        let decrypted = decrypt_block(&encrypted, &key, ctx()).unwrap();
432        assert_eq!(decrypted, plaintext);
433    }
434
435    /// AES-GCM proves *who* wrote a block, never *which* block it is. With
436    /// no associated data, one column's ciphertext decrypted cleanly in
437    /// another column's place — so a file holding a public column and a
438    /// private one under the same key leaked the private values to anyone
439    /// who could swap the bytes, and the authentication tag still verified.
440    #[test]
441    fn a_block_moved_to_another_column_fails_to_authenticate() {
442        let key = test_key();
443        let salary = encrypt_block(
444            b"180000",
445            &key,
446            BlockContext {
447                column: "salary",
448                segment_created_at: 42,
449            },
450        )
451        .unwrap();
452
453        let err = decrypt_block(
454            &salary,
455            &key,
456            BlockContext {
457                column: "public_note",
458                segment_created_at: 42,
459            },
460        )
461        .expect_err("a block must not decrypt under another column's name");
462        assert!(err.to_string().contains("decryption failed"), "{err}");
463    }
464
465    /// The same for a block lifted into another segment: the header's
466    /// creation timestamp is part of what the tag covers.
467    #[test]
468    fn a_block_moved_to_another_segment_fails_to_authenticate() {
469        let key = test_key();
470        let block = encrypt_block(
471            b"readings",
472            &key,
473            BlockContext {
474                column: "value",
475                segment_created_at: 1_000,
476            },
477        )
478        .unwrap();
479
480        assert!(
481            decrypt_block(
482                &block,
483                &key,
484                BlockContext {
485                    column: "value",
486                    segment_created_at: 2_000,
487                },
488            )
489            .is_err(),
490            "a block must not decrypt inside a different segment"
491        );
492
493        // And it still reads correctly where it belongs.
494        let plain = decrypt_block(
495            &block,
496            &key,
497            BlockContext {
498                column: "value",
499                segment_created_at: 1_000,
500            },
501        )
502        .expect("the block reads in its own place");
503        assert_eq!(plain, b"readings");
504    }
505}