diff --git a/Cargo.lock b/Cargo.lock index 4a3170a1b..1d8b43d55 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,41 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "ahash" version = "0.8.11" @@ -449,6 +484,16 @@ dependencies = [ "half", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "clap" version = "4.5.32" @@ -607,6 +652,15 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.4.2" @@ -732,6 +786,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] @@ -772,6 +827,15 @@ version = "0.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4f211af61d8efdd104f96e57adf5e426ba1bc3ed7a4ead616e15e5881fd79c4d" +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + [[package]] name = "ctrlc" version = "3.4.5" @@ -1335,6 +1399,16 @@ dependencies = [ "wasi 0.14.2+wasi-0.2.4", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "gimli" version = "0.31.1" @@ -1681,6 +1755,15 @@ dependencies = [ "libc", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "io-uring" version = "0.7.6" @@ -2435,6 +2518,12 @@ version = "11.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "option-ext" version = "0.2.0" @@ -2582,6 +2671,18 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + [[package]] name = "portable-atomic" version = "1.11.0" @@ -3362,6 +3463,12 @@ dependencies = [ "syn 2.0.100", ] +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "supports-color" version = "3.0.2" @@ -3850,6 +3957,8 @@ dependencies = [ name = "turso_core" version = "0.1.4-pre.10" dependencies = [ + "aes", + "aes-gcm", "antithesis_sdk", "bitflags 2.9.0", "built", @@ -4101,6 +4210,16 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "untrusted" version = "0.9.0" diff --git a/core/Cargo.toml b/core/Cargo.toml index f51f7c810..cd9632931 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -27,6 +27,7 @@ omit_autovacuum = [] simulator = ["fuzz", "serde"] serde = ["dep:serde"] series = [] +encryption = ["dep:aes-gcm", "dep:aes"] [target.'cfg(target_os = "linux")'.dependencies] io-uring = { version = "0.7.5", optional = true } @@ -73,6 +74,8 @@ uuid = { version = "1.11.0", features = ["v4", "v7"], optional = true } tempfile = "3.8.0" pack1 = { version = "1.0.0", features = ["bytemuck"] } bytemuck = "1.23.1" +aes-gcm = { version = "0.10.3", optional = true } +aes = { version = "0.8.4", optional = true } [build-dependencies] chrono = { version = "0.4.38", default-features = false } diff --git a/core/storage/encryption.rs b/core/storage/encryption.rs new file mode 100644 index 000000000..b308096fa --- /dev/null +++ b/core/storage/encryption.rs @@ -0,0 +1,187 @@ +use crate::Result; +use aes_gcm::{ + aead::{Aead, AeadCore, KeyInit, OsRng}, + Aes256Gcm, Key, Nonce, +}; +use std::ops::Deref; + +pub const ENCRYPTION_METADATA_SIZE: usize = 28; +pub const ENCRYPTED_PAGE_SIZE: usize = 4096; +pub const ENCRYPTION_NONCE_SIZE: usize = 12; + +#[repr(transparent)] +#[derive(Clone)] +pub struct EncryptionKey([u8; 32]); + +impl EncryptionKey { + pub fn new(key: [u8; 32]) -> Self { + Self(key) + } + + pub fn from_string(s: &str) -> Self { + let mut key = [0u8; 32]; + let bytes = s.as_bytes(); + let len = bytes.len().min(32); + key[..len].copy_from_slice(&bytes[..len]); + Self(key) + } + + pub fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } + + pub fn as_slice(&self) -> &[u8] { + &self.0 + } +} + +impl Deref for EncryptionKey { + type Target = [u8; 32]; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl AsRef<[u8; 32]> for EncryptionKey { + fn as_ref(&self) -> &[u8; 32] { + &self.0 + } +} + +impl std::fmt::Debug for EncryptionKey { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("EncryptionKey") + .field("key", &"") + .finish() + } +} + +impl Drop for EncryptionKey { + fn drop(&mut self) { + // securely zero out the key bytes before dropping + for byte in self.0.iter_mut() { + unsafe { + std::ptr::write_volatile(byte, 0); + } + } + } +} + +pub fn encrypt_page(page: &[u8], page_id: usize, key: &EncryptionKey) -> Result> { + if page_id == 1 { + tracing::debug!("skipping encryption for page 1 (database header)"); + return Ok(page.to_vec()); + } + tracing::debug!("encrypting page {}", page_id); + assert_eq!( + page.len(), + ENCRYPTED_PAGE_SIZE, + "Page data must be exactly {ENCRYPTED_PAGE_SIZE} bytes" + ); + let reserved_bytes = &page[ENCRYPTED_PAGE_SIZE - ENCRYPTION_METADATA_SIZE..]; + let reserved_bytes_zeroed = reserved_bytes.iter().all(|&b| b == 0); + assert!( + reserved_bytes_zeroed, + "last reserved bytes must be empty/zero, but found non-zero bytes" + ); + let payload = &page[..ENCRYPTED_PAGE_SIZE - ENCRYPTION_METADATA_SIZE]; + let (encrypted, nonce) = encrypt(payload, key)?; + assert_eq!( + encrypted.len(), + ENCRYPTED_PAGE_SIZE - nonce.len(), + "Encrypted page must be exactly {} bytes", + ENCRYPTED_PAGE_SIZE - nonce.len() + ); + let mut result = Vec::with_capacity(ENCRYPTED_PAGE_SIZE); + result.extend_from_slice(&encrypted); + result.extend_from_slice(&nonce); + assert_eq!( + result.len(), + ENCRYPTED_PAGE_SIZE, + "Encrypted page must be exactly {ENCRYPTED_PAGE_SIZE} bytes" + ); + Ok(result) +} + +pub fn decrypt_page(encrypted_page: &[u8], page_id: usize, key: &EncryptionKey) -> Result> { + if page_id == 1 { + tracing::debug!("skipping decryption for page 1 (database header)"); + return Ok(encrypted_page.to_vec()); + } + tracing::debug!("decrypting page {}", page_id); + assert_eq!( + encrypted_page.len(), + ENCRYPTED_PAGE_SIZE, + "Encrypted page data must be exactly {ENCRYPTED_PAGE_SIZE} bytes" + ); + + let nonce_start = encrypted_page.len() - ENCRYPTION_NONCE_SIZE; + let payload = &encrypted_page[..nonce_start]; + let nonce = &encrypted_page[nonce_start..]; + + let decrypted_data = decrypt(payload, nonce, key)?; + assert_eq!( + decrypted_data.len(), + ENCRYPTED_PAGE_SIZE - ENCRYPTION_METADATA_SIZE, + "Decrypted page data must be exactly {} bytes", + ENCRYPTED_PAGE_SIZE - ENCRYPTION_METADATA_SIZE + ); + let mut result = Vec::with_capacity(ENCRYPTED_PAGE_SIZE); + result.extend_from_slice(&decrypted_data); + result.resize(ENCRYPTED_PAGE_SIZE, 0); + assert_eq!( + result.len(), + ENCRYPTED_PAGE_SIZE, + "Decrypted page data must be exactly {ENCRYPTED_PAGE_SIZE} bytes" + ); + Ok(result) +} + +fn encrypt(plaintext: &[u8], key: &EncryptionKey) -> Result<(Vec, Vec)> { + let key: &Key = key.as_ref().into(); + let cipher = Aes256Gcm::new(key); + let nonce = Aes256Gcm::generate_nonce(&mut OsRng); + let ciphertext = cipher.encrypt(&nonce, plaintext).unwrap(); + Ok((ciphertext, nonce.to_vec())) +} + +fn decrypt(ciphertext: &[u8], nonce: &[u8], key: &EncryptionKey) -> Result> { + let key: &Key = key.as_ref().into(); + let cipher = Aes256Gcm::new(key); + let nonce = Nonce::from_slice(nonce); + let plaintext = cipher.decrypt(nonce, ciphertext).unwrap(); + Ok(plaintext) +} + +#[cfg(test)] +mod tests { + use super::*; + use rand::Rng; + + #[test] + fn test_encrypt_decrypt_round_trip() { + let mut rng = rand::thread_rng(); + let data_size = ENCRYPTED_PAGE_SIZE - ENCRYPTION_METADATA_SIZE; + + let page_data = { + let mut page = vec![0u8; ENCRYPTED_PAGE_SIZE]; + page.iter_mut() + .take(data_size) + .for_each(|byte| *byte = rng.gen()); + page + }; + + let key = EncryptionKey::from_string("alice and bob use encryption on database"); + + let page_id = 42; + let encrypted = encrypt_page(&page_data, page_id, &key).unwrap(); + assert_eq!(encrypted.len(), ENCRYPTED_PAGE_SIZE); + assert_ne!(&encrypted[..data_size], &page_data[..data_size]); + assert_ne!(&encrypted[..], &page_data[..]); + + let decrypted = decrypt_page(&encrypted, page_id, &key).unwrap(); + assert_eq!(decrypted.len(), ENCRYPTED_PAGE_SIZE); + assert_eq!(decrypted, page_data); + } +} diff --git a/core/storage/mod.rs b/core/storage/mod.rs index 944f192fc..bcba83d5a 100644 --- a/core/storage/mod.rs +++ b/core/storage/mod.rs @@ -13,6 +13,8 @@ pub(crate) mod btree; pub(crate) mod buffer_pool; pub(crate) mod database; +#[cfg(feature = "encryption")] +pub(crate) mod encryption; pub(crate) mod page_cache; #[allow(clippy::arc_with_non_send_sync)] pub(crate) mod pager;