mirror of
https://github.com/aljazceru/turso.git
synced 2025-12-25 12:04:21 +01:00
Merge 'Improve encryption module' from Avinash Sajjanshetty
This patch improves the encryption module: 1. Previously, we did not use the first 100 bytes in encryption. This patch uses that portion as associated data, for protection against tampering and corruption 2. Once the page 1 encrypted, on disk we store a special Turso header (the first 16 bytes). During decryption we replace this with standard SQLite's header (`"SQLite format 3\000"`). So that the upper layers (B Tree or in Sync APIs) operate on the existing SQLite page expectations. The format is: ``` /// Turso Header (16 bytes) /// ┌─────────┬───────┬────────┬──────────────────┐ /// │ │ │ │ │ /// │ Turso │Version│ Cipher │ Unused │ /// │ (5) │ (1) │ (1) │ (9 bytes) │ /// │ │ │ │ │ /// └─────────┴───────┴────────┴──────────────────┘ /// 0-4 5 6 7-15 /// /// Standard SQLite Header: "SQLite format 3\0" (16 bytes) /// ↓ /// Turso Encrypted Header: "Turso" + Version + Cipher ID + Unused ``` Reviewed-by: Nikita Sivukhin (@sivukhin) Reviewed-by: bit-aloo (@Shourya742) Closes #3358
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
#![allow(unused_variables, dead_code)]
|
||||
use crate::{LimboError, Result};
|
||||
use crate::{turso_assert, LimboError, Result};
|
||||
use aegis::aegis128l::Aegis128L;
|
||||
use aegis::aegis128x2::Aegis128X2;
|
||||
use aegis::aegis128x4::Aegis128X4;
|
||||
@@ -12,6 +12,66 @@ use aes_gcm::{
|
||||
};
|
||||
use turso_macros::match_ignore_ascii_case;
|
||||
|
||||
/// Encryption Scheme
|
||||
/// We support two major algorithms: AEGIS, AES GCM. These algorithms picked so that they also do
|
||||
/// verification of the ciphertext, so we don't need to implement. That is if the page is corrupted
|
||||
/// (or tampered), then we will know if we got garbage bytes post decryption.
|
||||
///
|
||||
/// We perform encryption at the page level, i.e., each page is encrypted and decrypted individually.
|
||||
/// We store the nonce and tag (or the verification bits) in the page itself. We also generate a
|
||||
/// random nonce every time we encrypt a page.
|
||||
///
|
||||
/// Example: Assume the page size is 4096 bytes and we use AEGIS 256. So we reserve the last 48 bytes
|
||||
/// for the nonce (32 bytes) and tag (16 bytes).
|
||||
///
|
||||
/// ```ignore
|
||||
/// Unencrypted Page Encrypted Page
|
||||
/// ┌───────────────┐ ┌───────────────┐
|
||||
/// │ │ │ │
|
||||
/// │ Page Content │ │ Encrypted │
|
||||
/// │ (4048 bytes) │ ────────► │ Content │
|
||||
/// │ │ │ (4048 bytes) │
|
||||
/// ├───────────────┤ ├───────────────┤
|
||||
/// │ Reserved │ │ Tag (32) │
|
||||
/// │ (48 bytes) │ ├───────────────┤
|
||||
/// │ [empty] │ │ Nonce (12) │
|
||||
/// └───────────────┘ └───────────────┘
|
||||
/// 4096 bytes 4096 bytes
|
||||
/// ```
|
||||
///
|
||||
/// The above applies to all the pages except Page 1. The page 1 contains the SQLite header (the
|
||||
/// first 100 bytes). Specifically, the bytes 16 to 24 contain metadata which is required to
|
||||
/// initialise the connection, which happens before we can setup the encryption context. So, we
|
||||
/// don't encrypt the header but instead use the header data as additional data (AD) for the
|
||||
/// encryption of the rest of the page. This provides us protection against tampering and
|
||||
/// corruption for the unencrypted portion.
|
||||
///
|
||||
/// On disk, the encrypted page 1 contains special bytes replacing the SQLite's magic bytes (the
|
||||
/// first 16 bytes):
|
||||
///
|
||||
/// ```ignore
|
||||
/// Turso Header (16 bytes)
|
||||
/// ┌─────────┬───────┬────────┬──────────────────┐
|
||||
/// │ │ │ │ │
|
||||
/// │ Turso │Version│ Cipher │ Unused │
|
||||
/// │ (5) │ (1) │ (1) │ (9 bytes) │
|
||||
/// │ │ │ │ │
|
||||
/// └─────────┴───────┴────────┴──────────────────┘
|
||||
/// 0-4 5 6 7-15
|
||||
///
|
||||
/// Standard SQLite Header: "SQLite format 3\0" (16 bytes)
|
||||
/// ↓
|
||||
/// Turso Encrypted Header: "Turso" + Version + Cipher ID + Unused
|
||||
/// ```
|
||||
///
|
||||
/// constants used for the Turso page header in the encrypted dbs.
|
||||
const TURSO_HEADER_PREFIX: &[u8] = b"Turso";
|
||||
const TURSO_VERSION: u8 = 0x00;
|
||||
const VERSION_OFFSET: usize = 5;
|
||||
const CIPHER_OFFSET: usize = 6;
|
||||
const TURSO_HEADER_SIZE: usize = 16;
|
||||
const SQLITE_HEADER: &[u8] = b"SQLite format 3\0";
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum EncryptionKey {
|
||||
Key128([u8; 16]),
|
||||
@@ -189,9 +249,12 @@ macro_rules! define_aes_gcm_cipher {
|
||||
})
|
||||
}
|
||||
|
||||
fn encrypt(&self, plaintext: &[u8], _ad: &[u8]) -> Result<(Vec<u8>, [u8; 12])> {
|
||||
fn encrypt(&self, plaintext: &[u8], ad: &[u8]) -> Result<(Vec<u8>, [u8; 12])> {
|
||||
let nonce = <$cipher_type>::generate_nonce(&mut OsRng);
|
||||
let ciphertext = self.cipher.encrypt(&nonce, plaintext).map_err(|e| {
|
||||
let ciphertext = self.cipher.encrypt(&nonce, aes_gcm::aead::Payload {
|
||||
msg: plaintext,
|
||||
aad: ad,
|
||||
}).map_err(|e| {
|
||||
LimboError::InternalError(format!("{} encryption failed: {e:?}", $name))
|
||||
})?;
|
||||
let mut nonce_array = [0u8; 12];
|
||||
@@ -199,10 +262,13 @@ macro_rules! define_aes_gcm_cipher {
|
||||
Ok((ciphertext, nonce_array))
|
||||
}
|
||||
|
||||
fn decrypt(&self, ciphertext: &[u8], nonce: &[u8; 12], _ad: &[u8]) -> Result<Vec<u8>> {
|
||||
fn decrypt(&self, ciphertext: &[u8], nonce: &[u8; 12], ad: &[u8]) -> Result<Vec<u8>> {
|
||||
let nonce = Nonce::from_slice(nonce);
|
||||
self.cipher
|
||||
.decrypt(nonce, ciphertext)
|
||||
.decrypt(nonce, aes_gcm::aead::Payload {
|
||||
msg: ciphertext,
|
||||
aad: ad,
|
||||
})
|
||||
.map_err(|_| -> LimboError { CipherError::DecryptionFailed { cipher: $name }.into() })
|
||||
}
|
||||
}
|
||||
@@ -349,6 +415,37 @@ impl CipherMode {
|
||||
pub fn metadata_size(&self) -> usize {
|
||||
self.nonce_size() + self.tag_size()
|
||||
}
|
||||
|
||||
/// Returns the cipher identifier byte for Turso header
|
||||
pub fn cipher_id(&self) -> u8 {
|
||||
match self {
|
||||
CipherMode::Aes128Gcm => 1,
|
||||
CipherMode::Aes256Gcm => 2,
|
||||
CipherMode::Aegis256 => 3,
|
||||
CipherMode::Aegis256X2 => 4,
|
||||
CipherMode::Aegis256X4 => 5,
|
||||
CipherMode::Aegis128L => 6,
|
||||
CipherMode::Aegis128X2 => 7,
|
||||
CipherMode::Aegis128X4 => 8,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a CipherMode from cipher identifier byte. This is used when read from Turso header.
|
||||
pub fn from_cipher_id(id: u8) -> Result<Self> {
|
||||
match id {
|
||||
1 => Ok(CipherMode::Aes128Gcm),
|
||||
2 => Ok(CipherMode::Aes256Gcm),
|
||||
3 => Ok(CipherMode::Aegis256),
|
||||
4 => Ok(CipherMode::Aegis256X2),
|
||||
5 => Ok(CipherMode::Aegis256X4),
|
||||
6 => Ok(CipherMode::Aegis128L),
|
||||
7 => Ok(CipherMode::Aegis128X2),
|
||||
8 => Ok(CipherMode::Aegis128X4),
|
||||
_ => Err(LimboError::InvalidArgument(format!(
|
||||
"Unknown cipher ID: {id}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -423,9 +520,73 @@ impl EncryptionContext {
|
||||
self.cipher_mode.metadata_size() as u8
|
||||
}
|
||||
|
||||
/// Creates Turso header for encrypted page 1
|
||||
fn create_turso_header(&self) -> [u8; TURSO_HEADER_SIZE] {
|
||||
let mut header = [0u8; TURSO_HEADER_SIZE];
|
||||
|
||||
// "Turso" prefix (5 bytes)
|
||||
header[..TURSO_HEADER_PREFIX.len()].copy_from_slice(TURSO_HEADER_PREFIX);
|
||||
|
||||
// version byte (1 byte)
|
||||
header[VERSION_OFFSET] = TURSO_VERSION;
|
||||
|
||||
// cipher identifier (1 byte)
|
||||
header[CIPHER_OFFSET] = self.cipher_mode.cipher_id();
|
||||
|
||||
// remaining unused 9 bytes
|
||||
header
|
||||
}
|
||||
|
||||
/// Validates and extracts cipher mode from Turso header
|
||||
fn validate_turso_header(&self, header: &[u8]) -> Result<()> {
|
||||
if header.len() < TURSO_HEADER_SIZE {
|
||||
return Err(LimboError::InternalError(
|
||||
"Header too short for encrypted Turso db".into(),
|
||||
));
|
||||
}
|
||||
|
||||
if &header[..TURSO_HEADER_PREFIX.len()] != TURSO_HEADER_PREFIX {
|
||||
return Err(LimboError::InternalError(
|
||||
"Invalid Turso header: prefix mismatch".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let version = header[VERSION_OFFSET];
|
||||
if version != TURSO_VERSION {
|
||||
return Err(LimboError::InternalError(format!(
|
||||
"Unsupported Turso header version: expected {TURSO_VERSION}, got {version}"
|
||||
)));
|
||||
}
|
||||
|
||||
let cipher_id = header[CIPHER_OFFSET];
|
||||
let header_cipher = CipherMode::from_cipher_id(cipher_id)?;
|
||||
if header_cipher != self.cipher_mode {
|
||||
return Err(LimboError::InternalError(format!(
|
||||
"Cipher mode mismatch: expected {:?} (ID {}), got {:?} (ID {})",
|
||||
self.cipher_mode,
|
||||
self.cipher_mode.cipher_id(),
|
||||
header_cipher,
|
||||
cipher_id
|
||||
)));
|
||||
}
|
||||
|
||||
if header[CIPHER_OFFSET + 1..TURSO_HEADER_SIZE]
|
||||
.iter()
|
||||
.any(|&b| b != 0)
|
||||
{
|
||||
return Err(LimboError::InternalError(
|
||||
"Invalid Turso header: unused bytes must be zero".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(feature = "encryption")]
|
||||
pub fn encrypt_page(&self, page: &[u8], page_id: usize) -> Result<Vec<u8>> {
|
||||
use crate::storage::sqlite3_ondisk::DatabaseHeader;
|
||||
if page_id == DatabaseHeader::PAGE_ID {
|
||||
return self.encrypt_page_1(page);
|
||||
}
|
||||
tracing::debug!("encrypting page {}", page_id);
|
||||
assert_eq!(
|
||||
page.len(),
|
||||
@@ -434,19 +595,12 @@ impl EncryptionContext {
|
||||
self.page_size
|
||||
);
|
||||
|
||||
let encryption_start_offset = match page_id {
|
||||
DatabaseHeader::PAGE_ID => DatabaseHeader::SIZE,
|
||||
_ => 0,
|
||||
};
|
||||
let metadata_size = self.cipher_mode.metadata_size();
|
||||
let reserved_bytes = &page[self.page_size - metadata_size..];
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
use crate::turso_assert;
|
||||
// In debug builds, ensure that the reserved bytes are zeroed out. So even when we are
|
||||
// reusing a page from buffer pool, we zero out in debug build so that we can be
|
||||
// sure that b tree layer is not writing any data into the reserved space.
|
||||
let reserved_bytes_zeroed = reserved_bytes.iter().all(|&b| b == 0);
|
||||
turso_assert!(
|
||||
reserved_bytes_zeroed,
|
||||
@@ -454,20 +608,18 @@ impl EncryptionContext {
|
||||
);
|
||||
}
|
||||
|
||||
let payload = &page[encryption_start_offset..self.page_size - metadata_size];
|
||||
let payload = &page[..self.page_size - metadata_size];
|
||||
let (encrypted, nonce) = self.encrypt_raw(payload)?;
|
||||
|
||||
let nonce_size = self.cipher_mode.nonce_size();
|
||||
assert_eq!(
|
||||
encrypted.len(),
|
||||
self.page_size - nonce_size - encryption_start_offset,
|
||||
self.page_size - nonce_size,
|
||||
"Encrypted page must be exactly {} bytes",
|
||||
self.page_size - nonce_size - encryption_start_offset
|
||||
self.page_size - nonce_size
|
||||
);
|
||||
|
||||
let mut result = Vec::with_capacity(self.page_size);
|
||||
|
||||
result.extend_from_slice(&page[..encryption_start_offset]);
|
||||
result.extend_from_slice(&encrypted);
|
||||
result.extend_from_slice(&nonce);
|
||||
assert_eq!(
|
||||
@@ -482,6 +634,9 @@ impl EncryptionContext {
|
||||
#[cfg(feature = "encryption")]
|
||||
pub fn decrypt_page(&self, encrypted_page: &[u8], page_id: usize) -> Result<Vec<u8>> {
|
||||
use crate::storage::sqlite3_ondisk::DatabaseHeader;
|
||||
if page_id == DatabaseHeader::PAGE_ID {
|
||||
return self.decrypt_page_1(encrypted_page);
|
||||
}
|
||||
tracing::debug!("decrypting page {}", page_id);
|
||||
assert_eq!(
|
||||
encrypted_page.len(),
|
||||
@@ -489,32 +644,147 @@ impl EncryptionContext {
|
||||
"Encrypted page data must be exactly {} bytes",
|
||||
self.page_size
|
||||
);
|
||||
// for page 1, the encrypted page starts after the database header
|
||||
// for other pages, the encrypted page starts at the beginning
|
||||
let encrypted_page_offset = match page_id {
|
||||
DatabaseHeader::PAGE_ID => DatabaseHeader::SIZE,
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
let nonce_size = self.cipher_mode.nonce_size();
|
||||
let nonce_offset = encrypted_page.len() - nonce_size;
|
||||
let payload = &encrypted_page[encrypted_page_offset..nonce_offset];
|
||||
let payload = &encrypted_page[..nonce_offset];
|
||||
let nonce = &encrypted_page[nonce_offset..];
|
||||
|
||||
let decrypted_data = self.decrypt_raw(payload, nonce)?;
|
||||
let metadata_size = self.cipher_mode.metadata_size();
|
||||
assert_eq!(
|
||||
decrypted_data.len(),
|
||||
self.page_size - metadata_size,
|
||||
"Decrypted page data must be exactly {} bytes",
|
||||
self.page_size - metadata_size
|
||||
);
|
||||
|
||||
let mut result = Vec::with_capacity(self.page_size);
|
||||
result.extend_from_slice(&decrypted_data);
|
||||
result.resize(self.page_size, 0);
|
||||
|
||||
assert_eq!(
|
||||
result.len(),
|
||||
self.page_size,
|
||||
"Decrypted page data must be exactly {} bytes",
|
||||
self.page_size
|
||||
);
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[cfg(feature = "encryption")]
|
||||
fn encrypt_page_1(&self, page: &[u8]) -> Result<Vec<u8>> {
|
||||
use crate::storage::sqlite3_ondisk::DatabaseHeader;
|
||||
|
||||
tracing::debug!("encrypting page 1");
|
||||
assert_eq!(
|
||||
page.len(),
|
||||
self.page_size,
|
||||
"Page data must be exactly {} bytes",
|
||||
self.page_size
|
||||
);
|
||||
|
||||
// since this is page 1, this must have header
|
||||
turso_assert!(
|
||||
page.starts_with(SQLITE_HEADER),
|
||||
"Page 1 must start with SQLite header"
|
||||
);
|
||||
|
||||
let metadata_size = self.cipher_mode.metadata_size();
|
||||
let reserved_bytes = &page[self.page_size - metadata_size..];
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
use crate::turso_assert;
|
||||
// In debug builds, ensure that the reserved bytes are zeroed out. So even when we are
|
||||
// reusing a page from buffer pool, we zero out in debug build so that we can be
|
||||
// sure that b tree layer is not writing any data into the reserved space.
|
||||
// We avoid calling `memset` in release builds for performance reasons.
|
||||
let reserved_bytes_zeroed = reserved_bytes.iter().all(|&b| b == 0);
|
||||
turso_assert!(
|
||||
reserved_bytes_zeroed,
|
||||
"last reserved bytes must be empty/zero, but found non-zero bytes"
|
||||
);
|
||||
}
|
||||
|
||||
// page 1 encryption:
|
||||
// 1. First 16 bytes are replaced with Turso magic bytes
|
||||
// 2. Next 84 bytes (16-100) are kept as-is (not encrypted)
|
||||
// 3. Remaining bytes (100-end) are encrypted
|
||||
// 4. The header (the first 100 bytes) as associated data
|
||||
let turso_header = self.create_turso_header();
|
||||
let mut new_header = Vec::with_capacity(DatabaseHeader::SIZE);
|
||||
new_header.extend_from_slice(&turso_header);
|
||||
new_header.extend_from_slice(&page[TURSO_HEADER_SIZE..DatabaseHeader::SIZE]);
|
||||
|
||||
let payload = &page[DatabaseHeader::SIZE..self.page_size - metadata_size];
|
||||
let (encrypted, nonce) = self.encrypt_raw_with_ad(payload, &new_header)?;
|
||||
|
||||
let nonce_size = self.cipher_mode.nonce_size();
|
||||
assert_eq!(
|
||||
encrypted.len(),
|
||||
self.page_size - nonce_size - DatabaseHeader::SIZE,
|
||||
"Encrypted page must be exactly {} bytes",
|
||||
self.page_size - nonce_size - DatabaseHeader::SIZE
|
||||
);
|
||||
|
||||
let mut result = Vec::with_capacity(self.page_size);
|
||||
|
||||
// 1. copy the header
|
||||
result.extend_from_slice(&new_header);
|
||||
// 2. copy the encrypted payload
|
||||
result.extend_from_slice(&encrypted);
|
||||
// 3. now add the nonce
|
||||
result.extend_from_slice(&nonce);
|
||||
|
||||
assert_eq!(
|
||||
result.len(),
|
||||
self.page_size,
|
||||
"Encrypted page must be exactly {} bytes",
|
||||
self.page_size
|
||||
);
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[cfg(feature = "encryption")]
|
||||
fn decrypt_page_1(&self, encrypted_page: &[u8]) -> Result<Vec<u8>> {
|
||||
use crate::storage::sqlite3_ondisk::DatabaseHeader;
|
||||
|
||||
tracing::debug!("decrypting page 1");
|
||||
assert_eq!(
|
||||
encrypted_page.len(),
|
||||
self.page_size,
|
||||
"Encrypted page data must be exactly {} bytes",
|
||||
self.page_size
|
||||
);
|
||||
|
||||
self.validate_turso_header(&encrypted_page[..TURSO_HEADER_SIZE])?;
|
||||
|
||||
let nonce_size = self.cipher_mode.nonce_size();
|
||||
let nonce_offset = encrypted_page.len() - nonce_size;
|
||||
let payload = &encrypted_page[DatabaseHeader::SIZE..nonce_offset];
|
||||
let nonce = &encrypted_page[nonce_offset..];
|
||||
|
||||
// it's important to use the header on disk (with Turso magic bytes) as associated data
|
||||
// for protection against tampering the header
|
||||
let header = &encrypted_page[..DatabaseHeader::SIZE];
|
||||
let decrypted_data = self.decrypt_raw_with_ad(payload, nonce, header)?;
|
||||
|
||||
let metadata_size = self.cipher_mode.metadata_size();
|
||||
assert_eq!(
|
||||
decrypted_data.len(),
|
||||
self.page_size - metadata_size - encrypted_page_offset,
|
||||
self.page_size - metadata_size - DatabaseHeader::SIZE,
|
||||
"Decrypted page data must be exactly {} bytes",
|
||||
self.page_size - metadata_size - encrypted_page_offset
|
||||
self.page_size - metadata_size - DatabaseHeader::SIZE
|
||||
);
|
||||
|
||||
// reconstruct the page with the appropriate SQLite header
|
||||
let mut result = Vec::with_capacity(self.page_size);
|
||||
result.extend_from_slice(&encrypted_page[..encrypted_page_offset]);
|
||||
result.extend_from_slice(SQLITE_HEADER);
|
||||
result.extend_from_slice(&encrypted_page[TURSO_HEADER_SIZE..DatabaseHeader::SIZE]);
|
||||
result.extend_from_slice(&decrypted_data);
|
||||
result.resize(self.page_size, 0);
|
||||
|
||||
assert_eq!(
|
||||
result.len(),
|
||||
self.page_size,
|
||||
@@ -527,10 +797,14 @@ impl EncryptionContext {
|
||||
/// encrypts raw data using the configured cipher, returns ciphertext and nonce
|
||||
fn encrypt_raw(&self, plaintext: &[u8]) -> Result<(Vec<u8>, Vec<u8>)> {
|
||||
const AD: &[u8] = b"";
|
||||
self.encrypt_raw_with_ad(plaintext, AD)
|
||||
}
|
||||
|
||||
/// encrypts raw data with associated data using the configured cipher
|
||||
fn encrypt_raw_with_ad(&self, plaintext: &[u8], ad: &[u8]) -> Result<(Vec<u8>, Vec<u8>)> {
|
||||
macro_rules! encrypt_cipher {
|
||||
($cipher:expr) => {{
|
||||
let (ciphertext, nonce) = $cipher.encrypt(plaintext, AD)?;
|
||||
let (ciphertext, nonce) = $cipher.encrypt(plaintext, ad)?;
|
||||
Ok((ciphertext, nonce.to_vec()))
|
||||
}};
|
||||
}
|
||||
@@ -549,7 +823,10 @@ impl EncryptionContext {
|
||||
|
||||
fn decrypt_raw(&self, ciphertext: &[u8], nonce: &[u8]) -> Result<Vec<u8>> {
|
||||
const AD: &[u8] = b"";
|
||||
self.decrypt_raw_with_ad(ciphertext, nonce, AD)
|
||||
}
|
||||
|
||||
fn decrypt_raw_with_ad(&self, ciphertext: &[u8], nonce: &[u8], ad: &[u8]) -> Result<Vec<u8>> {
|
||||
macro_rules! decrypt_with_nonce {
|
||||
($cipher:expr, $nonce_size:literal, $name:literal) => {{
|
||||
let nonce_array: [u8; $nonce_size] = nonce.try_into().map_err(|_| {
|
||||
@@ -560,7 +837,7 @@ impl EncryptionContext {
|
||||
nonce.len()
|
||||
))
|
||||
})?;
|
||||
$cipher.decrypt(ciphertext, &nonce_array, AD)
|
||||
$cipher.decrypt(ciphertext, &nonce_array, ad)
|
||||
}};
|
||||
}
|
||||
|
||||
@@ -715,6 +992,15 @@ mod tests {
|
||||
hex::encode(bytes)
|
||||
}
|
||||
|
||||
fn create_test_page_1() -> Vec<u8> {
|
||||
let mut page = vec![0u8; DEFAULT_ENCRYPTED_PAGE_SIZE];
|
||||
page[..SQLITE_HEADER.len()].copy_from_slice(SQLITE_HEADER);
|
||||
let mut rng = rand::thread_rng();
|
||||
// 48 is the max reserved bytes we might need for metadata with any cipher
|
||||
rng.fill(&mut page[SQLITE_HEADER.len()..DEFAULT_ENCRYPTED_PAGE_SIZE - 48]);
|
||||
page
|
||||
}
|
||||
|
||||
test_aes_cipher_wrapper!(
|
||||
test_aes128gcm_cipher_wrapper,
|
||||
Aes128GcmCipher,
|
||||
@@ -731,6 +1017,122 @@ mod tests {
|
||||
"Hello, AES-128-GCM!"
|
||||
);
|
||||
|
||||
#[test]
|
||||
fn test_page_1_encrypt_decrypt_round_trip_with_ad() {
|
||||
let key = EncryptionKey::from_hex_string(&generate_random_hex_key()).unwrap();
|
||||
let ctx = EncryptionContext::new(CipherMode::Aegis256, &key, DEFAULT_ENCRYPTED_PAGE_SIZE)
|
||||
.unwrap();
|
||||
|
||||
let page_data = create_test_page_1();
|
||||
let encrypted = ctx.encrypt_page(&page_data, 1).unwrap();
|
||||
assert_eq!(encrypted.len(), DEFAULT_ENCRYPTED_PAGE_SIZE);
|
||||
|
||||
// check that header is readable directly from disk (not encrypted)
|
||||
assert_eq!(&encrypted[..5], b"Turso");
|
||||
assert_eq!(encrypted[5], TURSO_VERSION);
|
||||
assert_eq!(encrypted[6], CipherMode::Aegis256.cipher_id());
|
||||
|
||||
// header should be unencrypted, but data after DatabaseHeader::SIZE should be different
|
||||
assert_eq!(&encrypted[16..100], &page_data[16..100]); // header portion
|
||||
assert_ne!(&encrypted[100..200], &page_data[100..200]); // some encrypted portion
|
||||
|
||||
// decrypt page 1
|
||||
let decrypted = ctx.decrypt_page(&encrypted, 1).unwrap();
|
||||
assert_eq!(decrypted.len(), DEFAULT_ENCRYPTED_PAGE_SIZE);
|
||||
|
||||
// check that SQLite header was restored
|
||||
assert_eq!(&decrypted[..SQLITE_HEADER.len()], SQLITE_HEADER);
|
||||
assert_eq!(decrypted, page_data);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_turso_header_validation() {
|
||||
let key = EncryptionKey::from_hex_string(&generate_random_hex_key()).unwrap();
|
||||
let ctx = EncryptionContext::new(CipherMode::Aegis256, &key, DEFAULT_ENCRYPTED_PAGE_SIZE)
|
||||
.unwrap();
|
||||
|
||||
// test cipher_id conversion
|
||||
assert_eq!(CipherMode::Aes128Gcm.cipher_id(), 1);
|
||||
assert_eq!(CipherMode::Aes256Gcm.cipher_id(), 2);
|
||||
assert_eq!(CipherMode::Aegis256.cipher_id(), 3);
|
||||
assert_eq!(CipherMode::Aegis128L.cipher_id(), 6);
|
||||
|
||||
// test from_cipher_id conversion
|
||||
assert_eq!(
|
||||
CipherMode::from_cipher_id(1).unwrap(),
|
||||
CipherMode::Aes128Gcm
|
||||
);
|
||||
assert_eq!(CipherMode::from_cipher_id(3).unwrap(), CipherMode::Aegis256);
|
||||
assert!(CipherMode::from_cipher_id(99).is_err());
|
||||
|
||||
// test header creation
|
||||
let header = ctx.create_turso_header();
|
||||
assert_eq!(&header[..5], b"Turso");
|
||||
assert_eq!(header[5], TURSO_VERSION);
|
||||
assert_eq!(header[6], 3); // AEGIS-256
|
||||
assert_eq!(&header[7..], &[0u8; 9]); // unused bytes are zero
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_turso_header_fails_decrypt() {
|
||||
let key = EncryptionKey::from_hex_string(&generate_random_hex_key()).unwrap();
|
||||
let ctx = EncryptionContext::new(CipherMode::Aegis256, &key, DEFAULT_ENCRYPTED_PAGE_SIZE)
|
||||
.unwrap();
|
||||
|
||||
let page_data = create_test_page_1();
|
||||
let encrypted = ctx.encrypt_page(&page_data, 1).unwrap();
|
||||
|
||||
// corrupt the header prefix
|
||||
let mut corrupted = encrypted.clone();
|
||||
corrupted[0] = b'V'; // make `Turso` to `Vurso`
|
||||
assert!(ctx.decrypt_page(&corrupted, 1).is_err());
|
||||
|
||||
// test with wrong cipher ID
|
||||
let mut wrong_cipher = encrypted.clone();
|
||||
wrong_cipher[6] = 99; // invalid cipher ID
|
||||
assert!(ctx.decrypt_page(&wrong_cipher, 1).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_associated_data_validation() {
|
||||
let key = EncryptionKey::from_hex_string(&generate_random_hex_key()).unwrap();
|
||||
let ctx = EncryptionContext::new(CipherMode::Aegis256, &key, DEFAULT_ENCRYPTED_PAGE_SIZE)
|
||||
.unwrap();
|
||||
|
||||
let page_data = create_test_page_1();
|
||||
let encrypted = ctx.encrypt_page(&page_data, 1).unwrap();
|
||||
|
||||
// modify a byte in the preserved header portion (bytes 16-100)
|
||||
let mut corrupted_ad = encrypted.clone();
|
||||
corrupted_ad[50] ^= 1; // flip one bit in the associated data portion
|
||||
|
||||
// this should fail decryption because associated data doesn't match
|
||||
let decrypt_result = ctx.decrypt_page(&corrupted_ad, 1);
|
||||
assert!(
|
||||
decrypt_result.is_err(),
|
||||
"Decryption should fail with corrupted associated data"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_turso_header_corruption_detection() {
|
||||
let key = EncryptionKey::from_hex_string(&generate_random_hex_key()).unwrap();
|
||||
let ctx = EncryptionContext::new(CipherMode::Aegis256, &key, DEFAULT_ENCRYPTED_PAGE_SIZE)
|
||||
.unwrap();
|
||||
|
||||
let page_data = create_test_page_1();
|
||||
let encrypted = ctx.encrypt_page(&page_data, 1).unwrap();
|
||||
|
||||
let mut corrupted_turso_header = encrypted.clone();
|
||||
corrupted_turso_header[7] ^= 1;
|
||||
|
||||
let decrypt_result = ctx.decrypt_page(&corrupted_turso_header, 1);
|
||||
assert!(
|
||||
decrypt_result.is_err(),
|
||||
"Decryption should fail with corrupted Turso header"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_aes128gcm_encrypt_decrypt_round_trip() {
|
||||
let mut rng = rand::thread_rng();
|
||||
|
||||
@@ -196,3 +196,259 @@ fn test_non_4k_page_size_encryption() -> anyhow::Result<()> {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_corruption_turso_magic_bytes() -> anyhow::Result<()> {
|
||||
let _ = env_logger::try_init();
|
||||
let db_name = format!("test-corruption-magic-{}.db", rng().next_u32());
|
||||
let tmp_db = TempDatabase::new(&db_name, false);
|
||||
let db_path = tmp_db.path.clone();
|
||||
|
||||
{
|
||||
let conn = tmp_db.connect_limbo();
|
||||
run_query(
|
||||
&tmp_db,
|
||||
&conn,
|
||||
"PRAGMA hexkey = 'b1bbfda4f589dc9daaf004fe21111e00dc00c98237102f5c7002a5669fc76327';",
|
||||
)?;
|
||||
run_query(&tmp_db, &conn, "PRAGMA cipher = 'aegis256';")?;
|
||||
run_query(
|
||||
&tmp_db,
|
||||
&conn,
|
||||
"CREATE TABLE test (id INTEGER PRIMARY KEY, value TEXT);",
|
||||
)?;
|
||||
run_query(
|
||||
&tmp_db,
|
||||
&conn,
|
||||
"INSERT INTO test (value) VALUES ('Test corruption')",
|
||||
)?;
|
||||
run_query(&tmp_db, &conn, "PRAGMA wal_checkpoint(TRUNCATE);")?;
|
||||
do_flush(&conn, &tmp_db)?;
|
||||
}
|
||||
|
||||
// corrupt the Turso magic bytes by changing "Turso" to "Vurso" (the db name as it was intended)
|
||||
{
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::{Seek, SeekFrom, Write};
|
||||
|
||||
let mut file = OpenOptions::new().write(true).open(&db_path)?;
|
||||
|
||||
file.seek(SeekFrom::Start(0))?;
|
||||
file.write_all(b"V")?;
|
||||
}
|
||||
|
||||
// try to connect to the corrupted database - this should fail
|
||||
{
|
||||
let uri = format!(
|
||||
"file:{}?cipher=aegis256&hexkey=b1bbfda4f589dc9daaf004fe21111e00dc00c98237102f5c7002a5669fc76327",
|
||||
db_path.to_str().unwrap()
|
||||
);
|
||||
|
||||
let should_panic = panic::catch_unwind(panic::AssertUnwindSafe(|| {
|
||||
let (_io, conn) =
|
||||
turso_core::Connection::from_uri(&uri, true, false, false, false).unwrap();
|
||||
run_query_on_row(&tmp_db, &conn, "SELECT * FROM test", |_row: &Row| {}).unwrap();
|
||||
}));
|
||||
|
||||
assert!(
|
||||
should_panic.is_err(),
|
||||
"should panic when accessing encrypted DB with corrupted Turso magic bytes"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_corruption_associated_data_bytes() -> anyhow::Result<()> {
|
||||
let _ = env_logger::try_init();
|
||||
let db_name = format!("test-corruption-ad-{}.db", rng().next_u32());
|
||||
let tmp_db = TempDatabase::new(&db_name, false);
|
||||
let db_path = tmp_db.path.clone();
|
||||
|
||||
{
|
||||
let conn = tmp_db.connect_limbo();
|
||||
run_query(
|
||||
&tmp_db,
|
||||
&conn,
|
||||
"PRAGMA hexkey = 'b1bbfda4f589dc9daaf004fe21111e00dc00c98237102f5c7002a5669fc76327';",
|
||||
)?;
|
||||
run_query(&tmp_db, &conn, "PRAGMA cipher = 'aegis256';")?;
|
||||
run_query(
|
||||
&tmp_db,
|
||||
&conn,
|
||||
"CREATE TABLE test (id INTEGER PRIMARY KEY, value TEXT);",
|
||||
)?;
|
||||
run_query(
|
||||
&tmp_db,
|
||||
&conn,
|
||||
"INSERT INTO test (value) VALUES ('Test AD corruption')",
|
||||
)?;
|
||||
run_query(&tmp_db, &conn, "PRAGMA wal_checkpoint(TRUNCATE);")?;
|
||||
do_flush(&conn, &tmp_db)?;
|
||||
}
|
||||
|
||||
// test corruption at different positions in the header (the first 100 bytes)
|
||||
let corruption_positions = [3, 7, 16, 30, 50, 70, 99];
|
||||
|
||||
for &corrupt_pos in &corruption_positions {
|
||||
let test_db_name = format!(
|
||||
"test-corruption-ad-pos-{}-{}.db",
|
||||
corrupt_pos,
|
||||
rng().next_u32()
|
||||
);
|
||||
let test_tmp_db = TempDatabase::new(&test_db_name, false);
|
||||
let test_db_path = test_tmp_db.path.clone();
|
||||
std::fs::copy(&db_path, &test_db_path)?;
|
||||
|
||||
// corrupt one byte
|
||||
{
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::{Read, Seek, SeekFrom, Write};
|
||||
|
||||
let mut file = OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(&test_db_path)?;
|
||||
|
||||
file.seek(SeekFrom::Start(corrupt_pos as u64))?;
|
||||
let mut original_byte = [0u8; 1];
|
||||
file.read_exact(&mut original_byte)?;
|
||||
|
||||
// corrupt it by flipping all bits
|
||||
let corrupted_byte = [!original_byte[0]];
|
||||
|
||||
file.seek(SeekFrom::Start(corrupt_pos as u64))?;
|
||||
file.write_all(&corrupted_byte)?;
|
||||
}
|
||||
|
||||
// this should fail
|
||||
{
|
||||
let uri = format!(
|
||||
"file:{}?cipher=aegis256&hexkey=b1bbfda4f589dc9daaf004fe21111e00dc00c98237102f5c7002a5669fc76327",
|
||||
test_db_path.to_str().unwrap()
|
||||
);
|
||||
|
||||
let should_panic = panic::catch_unwind(panic::AssertUnwindSafe(|| {
|
||||
let (_io, conn) =
|
||||
turso_core::Connection::from_uri(&uri, true, false, false, false).unwrap();
|
||||
run_query_on_row(&test_tmp_db, &conn, "SELECT * FROM test", |_row: &Row| {})
|
||||
.unwrap();
|
||||
}));
|
||||
|
||||
assert!(
|
||||
should_panic.is_err(),
|
||||
"should panic when accessing encrypted DB with corrupted associated data at position {corrupt_pos}",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_turso_header_structure() -> anyhow::Result<()> {
|
||||
let _ = env_logger::try_init();
|
||||
|
||||
let verify_header =
|
||||
|db_path: &str, expected_cipher_id: u8, description: &str| -> anyhow::Result<()> {
|
||||
use std::fs::File;
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
|
||||
let mut file = File::open(db_path)?;
|
||||
let mut header = [0u8; 16];
|
||||
file.seek(SeekFrom::Start(0))?;
|
||||
file.read_exact(&mut header)?;
|
||||
|
||||
assert_eq!(
|
||||
&header[0..5],
|
||||
b"Turso",
|
||||
"Magic bytes should be 'Turso' for {description}"
|
||||
);
|
||||
assert_eq!(header[5], 0x00, "Version should be 0x00 for {description}");
|
||||
assert_eq!(
|
||||
header[6], expected_cipher_id,
|
||||
"Cipher ID should be {expected_cipher_id} for {description}"
|
||||
);
|
||||
|
||||
// the unused bytes should be zeroed
|
||||
for (i, &byte) in header[7..16].iter().enumerate() {
|
||||
assert_eq!(
|
||||
byte,
|
||||
0,
|
||||
"Unused byte at position {} should be 0 for {}",
|
||||
i + 7,
|
||||
description
|
||||
);
|
||||
}
|
||||
|
||||
println!("Verified {} header: cipher ID = {}", description, header[6]);
|
||||
Ok(())
|
||||
};
|
||||
|
||||
let test_cases = [
|
||||
(
|
||||
"aes128gcm",
|
||||
1,
|
||||
"AES-128-GCM",
|
||||
"b1bbfda4f589dc9daaf004fe21111e00",
|
||||
),
|
||||
(
|
||||
"aes256gcm",
|
||||
2,
|
||||
"AES-256-GCM",
|
||||
"b1bbfda4f589dc9daaf004fe21111e00dc00c98237102f5c7002a5669fc76327",
|
||||
),
|
||||
(
|
||||
"aegis256",
|
||||
3,
|
||||
"AEGIS-256",
|
||||
"b1bbfda4f589dc9daaf004fe21111e00dc00c98237102f5c7002a5669fc76327",
|
||||
),
|
||||
(
|
||||
"aegis256x2",
|
||||
4,
|
||||
"AEGIS-256X2",
|
||||
"b1bbfda4f589dc9daaf004fe21111e00dc00c98237102f5c7002a5669fc76327",
|
||||
),
|
||||
(
|
||||
"aegis128l",
|
||||
6,
|
||||
"AEGIS-128L",
|
||||
"b1bbfda4f589dc9daaf004fe21111e00",
|
||||
),
|
||||
(
|
||||
"aegis128x2",
|
||||
7,
|
||||
"AEGIS-128X2",
|
||||
"b1bbfda4f589dc9daaf004fe21111e00",
|
||||
),
|
||||
(
|
||||
"aegis128x4",
|
||||
8,
|
||||
"AEGIS-128X4",
|
||||
"b1bbfda4f589dc9daaf004fe21111e00",
|
||||
),
|
||||
];
|
||||
|
||||
for (cipher_name, expected_id, description, hexkey) in test_cases {
|
||||
let db_name = format!("test-header-{}-{}.db", cipher_name, rng().next_u32());
|
||||
let tmp_db = TempDatabase::new(&db_name, false);
|
||||
let db_path = tmp_db.path.clone();
|
||||
|
||||
{
|
||||
let conn = tmp_db.connect_limbo();
|
||||
run_query(&tmp_db, &conn, &format!("PRAGMA hexkey = '{hexkey}';"))?;
|
||||
run_query(&tmp_db, &conn, &format!("PRAGMA cipher = '{cipher_name}';"))?;
|
||||
run_query(
|
||||
&tmp_db,
|
||||
&conn,
|
||||
"CREATE TABLE test (id INTEGER PRIMARY KEY, value TEXT);",
|
||||
)?;
|
||||
do_flush(&conn, &tmp_db)?;
|
||||
}
|
||||
|
||||
verify_header(db_path.to_str().unwrap(), expected_id, description)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user