mirror of
https://github.com/aljazceru/turso.git
synced 2025-12-18 17:14:20 +01:00
1543 lines
56 KiB
Rust
1543 lines
56 KiB
Rust
#![allow(unused_variables, dead_code)]
|
|
use crate::{LimboError, Result};
|
|
use aegis::aegis128l::Aegis128L;
|
|
use aegis::aegis128x2::Aegis128X2;
|
|
use aegis::aegis128x4::Aegis128X4;
|
|
use aegis::aegis256::Aegis256;
|
|
use aegis::aegis256x2::Aegis256X2;
|
|
use aegis::aegis256x4::Aegis256X4;
|
|
use aes_gcm::{
|
|
aead::{Aead, AeadCore, KeyInit, OsRng},
|
|
Aes128Gcm, Aes256Gcm, Key, Nonce,
|
|
};
|
|
use turso_macros::{match_ignore_ascii_case, AtomicEnum};
|
|
|
|
/// 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 (16) │
|
|
/// │ (48 bytes) │ ├───────────────┤
|
|
/// │ [empty] │ │ Nonce (32) │
|
|
/// └───────────────┘ └───────────────┘
|
|
/// 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]),
|
|
Key256([u8; 32]),
|
|
}
|
|
|
|
impl EncryptionKey {
|
|
pub fn new_256(key: [u8; 32]) -> Self {
|
|
Self::Key256(key)
|
|
}
|
|
|
|
pub fn new_128(key: [u8; 16]) -> Self {
|
|
Self::Key128(key)
|
|
}
|
|
|
|
pub fn from_hex_string(s: &str) -> Result<Self> {
|
|
let hex_str = s.trim();
|
|
let bytes = hex::decode(hex_str)
|
|
.map_err(|e| LimboError::InvalidArgument(format!("Invalid hex string: {e}")))?;
|
|
|
|
match bytes.len() {
|
|
16 => {
|
|
let key: [u8; 16] = bytes.try_into().unwrap();
|
|
Ok(Self::Key128(key))
|
|
}
|
|
32 => {
|
|
let key: [u8; 32] = bytes.try_into().unwrap();
|
|
Ok(Self::Key256(key))
|
|
}
|
|
_ => Err(LimboError::InvalidArgument(format!(
|
|
"Hex string must decode to exactly 16 or 32 bytes, got {}",
|
|
bytes.len()
|
|
))),
|
|
}
|
|
}
|
|
|
|
pub fn as_slice(&self) -> &[u8] {
|
|
match self {
|
|
Self::Key128(key) => key,
|
|
Self::Key256(key) => key,
|
|
}
|
|
}
|
|
|
|
#[allow(clippy::len_without_is_empty)]
|
|
pub fn len(&self) -> usize {
|
|
match self {
|
|
Self::Key128(_) => 16,
|
|
Self::Key256(_) => 32,
|
|
}
|
|
}
|
|
|
|
pub fn as_128(&self) -> Option<&[u8; 16]> {
|
|
match self {
|
|
Self::Key128(key) => Some(key),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
pub fn as_256(&self) -> Option<&[u8; 32]> {
|
|
match self {
|
|
Self::Key256(key) => Some(key),
|
|
_ => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::fmt::Debug for EncryptionKey {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.debug_struct("EncryptionKey")
|
|
.field("key", &"<encryption key redacted>")
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
impl Drop for EncryptionKey {
|
|
fn drop(&mut self) {
|
|
// securely zero out the key bytes before dropping
|
|
match self {
|
|
Self::Key128(key) => {
|
|
for byte in key.iter_mut() {
|
|
unsafe {
|
|
std::ptr::write_volatile(byte, 0);
|
|
}
|
|
}
|
|
}
|
|
Self::Key256(key) => {
|
|
for byte in key.iter_mut() {
|
|
unsafe {
|
|
std::ptr::write_volatile(byte, 0);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
macro_rules! define_aegis_cipher {
|
|
($struct_name:ident, $cipher_type:ty, key128, $nonce_size:literal, $name:literal) => {
|
|
define_aegis_cipher!(@impl $struct_name, $cipher_type, $nonce_size, $name, 16, as_128);
|
|
};
|
|
($struct_name:ident, $cipher_type:ty, key256, $nonce_size:literal, $name:literal) => {
|
|
define_aegis_cipher!(@impl $struct_name, $cipher_type, $nonce_size, $name, 32, as_256);
|
|
};
|
|
(@impl $struct_name:ident, $cipher_type:ty, $nonce_size:literal, $name:literal, $key_size:literal, $key_method:ident) => {
|
|
#[derive(Clone)]
|
|
pub struct $struct_name {
|
|
key: EncryptionKey,
|
|
}
|
|
|
|
impl $struct_name {
|
|
const TAG_SIZE: usize = 16;
|
|
|
|
fn new(key: &EncryptionKey) -> Self {
|
|
Self { key: key.clone() }
|
|
}
|
|
|
|
fn encrypt(&self, plaintext: &[u8], ad: &[u8]) -> Result<(Vec<u8>, [u8; $nonce_size])> {
|
|
let nonce = generate_secure_nonce::<$nonce_size>();
|
|
let key_bytes = self.key.$key_method()
|
|
.ok_or_else(|| -> LimboError { CipherError::InvalidKeySize { cipher: $name, expected: $key_size }.into() })?;
|
|
let (ciphertext, tag) = <$cipher_type>::new(key_bytes, &nonce).encrypt(plaintext, ad);
|
|
let mut result = ciphertext;
|
|
result.extend_from_slice(&tag);
|
|
Ok((result, nonce))
|
|
}
|
|
|
|
fn decrypt(&self, ciphertext: &[u8], nonce: &[u8; $nonce_size], ad: &[u8]) -> Result<Vec<u8>> {
|
|
if ciphertext.len() < Self::TAG_SIZE {
|
|
return Err(LimboError::from(CipherError::CiphertextTooShort { cipher: $name }));
|
|
}
|
|
let (ct, tag) = ciphertext.split_at(ciphertext.len() - Self::TAG_SIZE);
|
|
let tag_array: [u8; 16] = tag.try_into().map_err(|_| -> LimboError { CipherError::InvalidTagSize { cipher: $name }.into() })?;
|
|
|
|
let key_bytes = self.key.$key_method()
|
|
.ok_or_else(|| -> LimboError { CipherError::InvalidKeySize { cipher: $name, expected: $key_size }.into() })?;
|
|
<$cipher_type>::new(key_bytes, nonce)
|
|
.decrypt(ct, &tag_array, ad)
|
|
.map_err(|_| -> LimboError { CipherError::DecryptionFailed { cipher: $name }.into() })
|
|
}
|
|
}
|
|
|
|
impl std::fmt::Debug for $struct_name {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.debug_struct(stringify!($struct_name))
|
|
.field("key", &"<redacted>")
|
|
.finish()
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
macro_rules! define_aes_gcm_cipher {
|
|
($struct_name:ident, $cipher_type:ty, key128, $name:literal) => {
|
|
define_aes_gcm_cipher!(@impl $struct_name, $cipher_type, $name, 16, as_128);
|
|
};
|
|
($struct_name:ident, $cipher_type:ty, key256, $name:literal) => {
|
|
define_aes_gcm_cipher!(@impl $struct_name, $cipher_type, $name, 32, as_256);
|
|
};
|
|
(@impl $struct_name:ident, $cipher_type:ty, $name:literal, $key_size:literal, $key_method:ident) => {
|
|
#[derive(Clone)]
|
|
pub struct $struct_name {
|
|
cipher: $cipher_type,
|
|
}
|
|
|
|
impl $struct_name {
|
|
const TAG_SIZE: usize = 16;
|
|
const NONCE_SIZE: usize = 12;
|
|
|
|
fn new(key: &EncryptionKey) -> Result<Self> {
|
|
let key_bytes = key.$key_method()
|
|
.ok_or_else(|| -> LimboError { CipherError::InvalidKeySize { cipher: $name, expected: $key_size }.into() })?;
|
|
let cipher_key: &Key<$cipher_type> = key_bytes.into();
|
|
Ok(Self {
|
|
cipher: <$cipher_type>::new(cipher_key),
|
|
})
|
|
}
|
|
|
|
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, aes_gcm::aead::Payload {
|
|
msg: plaintext,
|
|
aad: ad,
|
|
}).map_err(|e| {
|
|
LimboError::InternalError(format!("{} encryption failed: {e:?}", $name))
|
|
})?;
|
|
let mut nonce_array = [0u8; 12];
|
|
nonce_array.copy_from_slice(&nonce);
|
|
Ok((ciphertext, nonce_array))
|
|
}
|
|
|
|
fn decrypt(&self, ciphertext: &[u8], nonce: &[u8; 12], ad: &[u8]) -> Result<Vec<u8>> {
|
|
let nonce = Nonce::from_slice(nonce);
|
|
self.cipher
|
|
.decrypt(nonce, aes_gcm::aead::Payload {
|
|
msg: ciphertext,
|
|
aad: ad,
|
|
})
|
|
.map_err(|_| -> LimboError { CipherError::DecryptionFailed { cipher: $name }.into() })
|
|
}
|
|
}
|
|
|
|
impl std::fmt::Debug for $struct_name {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.debug_struct(stringify!($struct_name))
|
|
.field("key", &"<redacted>")
|
|
.finish()
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
// AES-GCM ciphers
|
|
define_aes_gcm_cipher!(Aes128GcmCipher, Aes128Gcm, key128, "AES-128-GCM");
|
|
define_aes_gcm_cipher!(Aes256GcmCipher, Aes256Gcm, key256, "AES-256-GCM");
|
|
|
|
// AEGIS ciphers
|
|
define_aegis_cipher!(Aegis256Cipher, Aegis256::<16>, key256, 32, "AEGIS-256");
|
|
define_aegis_cipher!(
|
|
Aegis256X2Cipher,
|
|
Aegis256X2::<16>,
|
|
key256,
|
|
32,
|
|
"AEGIS-256X2"
|
|
);
|
|
define_aegis_cipher!(
|
|
Aegis256X4Cipher,
|
|
Aegis256X4::<16>,
|
|
key256,
|
|
32,
|
|
"AEGIS-256X4"
|
|
);
|
|
define_aegis_cipher!(
|
|
Aegis128X2Cipher,
|
|
Aegis128X2::<16>,
|
|
key128,
|
|
16,
|
|
"AEGIS-128X2"
|
|
);
|
|
define_aegis_cipher!(Aegis128LCipher, Aegis128L::<16>, key128, 16, "AEGIS-128L");
|
|
define_aegis_cipher!(
|
|
Aegis128X4Cipher,
|
|
Aegis128X4::<16>,
|
|
key128,
|
|
16,
|
|
"AEGIS-128X4"
|
|
);
|
|
|
|
#[derive(Debug, AtomicEnum, Clone, Copy, PartialEq)]
|
|
pub enum CipherMode {
|
|
None,
|
|
Aes128Gcm,
|
|
Aes256Gcm,
|
|
Aegis256,
|
|
Aegis128L,
|
|
Aegis128X2,
|
|
Aegis128X4,
|
|
Aegis256X2,
|
|
Aegis256X4,
|
|
}
|
|
|
|
impl TryFrom<&str> for CipherMode {
|
|
type Error = LimboError;
|
|
|
|
fn try_from(s: &str) -> Result<Self, Self::Error> {
|
|
let s_bytes = s.as_bytes();
|
|
match_ignore_ascii_case!(match s_bytes {
|
|
b"aes128gcm" | b"aes-128-gcm" | b"aes_128_gcm" => Ok(CipherMode::Aes128Gcm),
|
|
b"aes256gcm" | b"aes-256-gcm" | b"aes_256_gcm" => Ok(CipherMode::Aes256Gcm),
|
|
b"aegis256" | b"aegis-256" | b"aegis_256" => Ok(CipherMode::Aegis256),
|
|
b"aegis128l" | b"aegis-128l" | b"aegis_128l" => Ok(CipherMode::Aegis128L),
|
|
b"aegis128x2" | b"aegis-128x2" | b"aegis_128x2" => Ok(CipherMode::Aegis128X2),
|
|
b"aegis128x4" | b"aegis-128x4" | b"aegis_128x4" => Ok(CipherMode::Aegis128X4),
|
|
b"aegis256x2" | b"aegis-256x2" | b"aegis_256x2" => Ok(CipherMode::Aegis256X2),
|
|
b"aegis256x4" | b"aegis-256x4" | b"aegis_256x4" => Ok(CipherMode::Aegis256X4),
|
|
_ => Err(LimboError::InvalidArgument(format!(
|
|
"Unknown cipher name: {s}"
|
|
))),
|
|
})
|
|
}
|
|
}
|
|
|
|
impl std::fmt::Display for CipherMode {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
CipherMode::Aes128Gcm => write!(f, "aes128gcm"),
|
|
CipherMode::Aes256Gcm => write!(f, "aes256gcm"),
|
|
CipherMode::Aegis256 => write!(f, "aegis256"),
|
|
CipherMode::Aegis128L => write!(f, "aegis128l"),
|
|
CipherMode::Aegis128X2 => write!(f, "aegis128x2"),
|
|
CipherMode::Aegis128X4 => write!(f, "aegis128x4"),
|
|
CipherMode::Aegis256X2 => write!(f, "aegis256x2"),
|
|
CipherMode::Aegis256X4 => write!(f, "aegis256x4"),
|
|
CipherMode::None => write!(f, "None"),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl CipherMode {
|
|
/// Every cipher requires a specific key size. For 256-bit algorithms, this is 32 bytes.
|
|
/// For 128-bit algorithms, it would be 16 bytes, etc.
|
|
pub fn required_key_size(&self) -> usize {
|
|
match self {
|
|
CipherMode::Aes128Gcm => 16,
|
|
CipherMode::Aes256Gcm => 32,
|
|
CipherMode::Aegis256 => 32,
|
|
CipherMode::Aegis256X2 => 32,
|
|
CipherMode::Aegis256X4 => 32,
|
|
CipherMode::Aegis128L => 16,
|
|
CipherMode::Aegis128X2 => 16,
|
|
CipherMode::Aegis128X4 => 16,
|
|
CipherMode::None => 0,
|
|
}
|
|
}
|
|
|
|
/// Returns the nonce size for this cipher mode.
|
|
pub fn nonce_size(&self) -> usize {
|
|
match self {
|
|
CipherMode::Aes128Gcm => 12,
|
|
CipherMode::Aes256Gcm => 12,
|
|
CipherMode::Aegis256 => 32,
|
|
CipherMode::Aegis256X2 => 32,
|
|
CipherMode::Aegis256X4 => 32,
|
|
CipherMode::Aegis128L => 16,
|
|
CipherMode::Aegis128X2 => 16,
|
|
CipherMode::Aegis128X4 => 16,
|
|
CipherMode::None => 0,
|
|
}
|
|
}
|
|
|
|
/// Returns the authentication tag size for this cipher mode.
|
|
pub fn tag_size(&self) -> usize {
|
|
match self {
|
|
CipherMode::Aes128Gcm => 16,
|
|
CipherMode::Aes256Gcm => 16,
|
|
CipherMode::Aegis256 => 16,
|
|
CipherMode::Aegis256X2 => 16,
|
|
CipherMode::Aegis256X4 => 16,
|
|
CipherMode::Aegis128L => 16,
|
|
CipherMode::Aegis128X2 => 16,
|
|
CipherMode::Aegis128X4 => 16,
|
|
CipherMode::None => 0,
|
|
}
|
|
}
|
|
|
|
/// Returns the total metadata size (nonce + tag) for this cipher mode.
|
|
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,
|
|
CipherMode::None => 0,
|
|
}
|
|
}
|
|
|
|
/// 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)]
|
|
pub enum Cipher {
|
|
Aes128Gcm(Box<Aes128GcmCipher>),
|
|
Aes256Gcm(Box<Aes256GcmCipher>),
|
|
Aegis256(Box<Aegis256Cipher>),
|
|
Aegis256X2(Box<Aegis256X2Cipher>),
|
|
Aegis256X4(Box<Aegis256X4Cipher>),
|
|
Aegis128L(Box<Aegis128LCipher>),
|
|
Aegis128X2(Box<Aegis128X2Cipher>),
|
|
Aegis128X4(Box<Aegis128X4Cipher>),
|
|
}
|
|
|
|
impl std::fmt::Debug for Cipher {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
Cipher::Aes128Gcm(_) => write!(f, "Cipher::Aes128Gcm"),
|
|
Cipher::Aes256Gcm(_) => write!(f, "Cipher::Aes256Gcm"),
|
|
Cipher::Aegis256(_) => write!(f, "Cipher::Aegis256"),
|
|
Cipher::Aegis256X2(_) => write!(f, "Cipher::Aegis256X2"),
|
|
Cipher::Aegis256X4(_) => write!(f, "Cipher::Aegis256X4"),
|
|
Cipher::Aegis128L(_) => write!(f, "Cipher::Aegis128L"),
|
|
Cipher::Aegis128X2(_) => write!(f, "Cipher::Aegis128X2"),
|
|
Cipher::Aegis128X4(_) => write!(f, "Cipher::Aegis128X4"),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub struct EncryptionContext {
|
|
cipher_mode: CipherMode,
|
|
cipher: Cipher,
|
|
page_size: usize,
|
|
}
|
|
|
|
impl EncryptionContext {
|
|
pub fn new(cipher_mode: CipherMode, key: &EncryptionKey, page_size: usize) -> Result<Self> {
|
|
let required_size = cipher_mode.required_key_size();
|
|
if key.len() != required_size {
|
|
return Err(crate::LimboError::InvalidArgument(format!(
|
|
"Invalid key size for {:?}: expected {} bytes, got {}",
|
|
cipher_mode,
|
|
required_size,
|
|
key.len()
|
|
)));
|
|
}
|
|
|
|
let cipher = match cipher_mode {
|
|
CipherMode::Aes128Gcm => Cipher::Aes128Gcm(Box::new(Aes128GcmCipher::new(key)?)),
|
|
CipherMode::Aes256Gcm => Cipher::Aes256Gcm(Box::new(Aes256GcmCipher::new(key)?)),
|
|
CipherMode::Aegis256 => Cipher::Aegis256(Box::new(Aegis256Cipher::new(key))),
|
|
CipherMode::Aegis256X2 => Cipher::Aegis256X2(Box::new(Aegis256X2Cipher::new(key))),
|
|
CipherMode::Aegis256X4 => Cipher::Aegis256X4(Box::new(Aegis256X4Cipher::new(key))),
|
|
CipherMode::Aegis128L => Cipher::Aegis128L(Box::new(Aegis128LCipher::new(key))),
|
|
CipherMode::Aegis128X2 => Cipher::Aegis128X2(Box::new(Aegis128X2Cipher::new(key))),
|
|
CipherMode::Aegis128X4 => Cipher::Aegis128X4(Box::new(Aegis128X4Cipher::new(key))),
|
|
CipherMode::None => {
|
|
return Err(LimboError::InvalidArgument(
|
|
"must select valid CipherMode".into(),
|
|
))
|
|
}
|
|
};
|
|
Ok(Self {
|
|
cipher_mode,
|
|
cipher,
|
|
page_size,
|
|
})
|
|
}
|
|
|
|
pub fn cipher_mode(&self) -> CipherMode {
|
|
self.cipher_mode
|
|
}
|
|
|
|
/// Returns the number of reserved bytes required at the end of each page for encryption metadata.
|
|
pub fn required_reserved_bytes(&self) -> u8 {
|
|
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(),
|
|
self.page_size,
|
|
"Page data must be exactly {} bytes",
|
|
self.page_size
|
|
);
|
|
|
|
let metadata_size = self.cipher_mode.metadata_size();
|
|
let reserved_bytes = &page[self.page_size - metadata_size..];
|
|
|
|
#[cfg(debug_assertions)]
|
|
{
|
|
use crate::turso_assert;
|
|
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"
|
|
);
|
|
}
|
|
|
|
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,
|
|
"Encrypted page must be exactly {} bytes",
|
|
self.page_size - nonce_size
|
|
);
|
|
|
|
let mut result = Vec::with_capacity(self.page_size);
|
|
result.extend_from_slice(&encrypted);
|
|
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")]
|
|
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(),
|
|
self.page_size,
|
|
"Encrypted page data must be exactly {} bytes",
|
|
self.page_size
|
|
);
|
|
|
|
let nonce_size = self.cipher_mode.nonce_size();
|
|
let nonce_offset = encrypted_page.len() - nonce_size;
|
|
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
|
|
crate::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 - DatabaseHeader::SIZE,
|
|
"Decrypted page data must be exactly {} bytes",
|
|
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(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,
|
|
"Decrypted page data must be exactly {} bytes",
|
|
self.page_size
|
|
);
|
|
Ok(result)
|
|
}
|
|
|
|
/// 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)?;
|
|
Ok((ciphertext, nonce.to_vec()))
|
|
}};
|
|
}
|
|
|
|
match &self.cipher {
|
|
Cipher::Aes128Gcm(cipher) => encrypt_cipher!(cipher),
|
|
Cipher::Aes256Gcm(cipher) => encrypt_cipher!(cipher),
|
|
Cipher::Aegis256(cipher) => encrypt_cipher!(cipher),
|
|
Cipher::Aegis256X2(cipher) => encrypt_cipher!(cipher),
|
|
Cipher::Aegis256X4(cipher) => encrypt_cipher!(cipher),
|
|
Cipher::Aegis128L(cipher) => encrypt_cipher!(cipher),
|
|
Cipher::Aegis128X2(cipher) => encrypt_cipher!(cipher),
|
|
Cipher::Aegis128X4(cipher) => encrypt_cipher!(cipher),
|
|
}
|
|
}
|
|
|
|
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(|_| {
|
|
LimboError::InternalError(format!(
|
|
"Invalid nonce size for {}: expected {}, got {}",
|
|
$name,
|
|
$nonce_size,
|
|
nonce.len()
|
|
))
|
|
})?;
|
|
$cipher.decrypt(ciphertext, &nonce_array, ad)
|
|
}};
|
|
}
|
|
|
|
match &self.cipher {
|
|
Cipher::Aes128Gcm(cipher) => decrypt_with_nonce!(cipher, 12, "AES-128-GCM"),
|
|
Cipher::Aes256Gcm(cipher) => decrypt_with_nonce!(cipher, 12, "AES-256-GCM"),
|
|
Cipher::Aegis256(cipher) => decrypt_with_nonce!(cipher, 32, "AEGIS-256"),
|
|
Cipher::Aegis256X2(cipher) => decrypt_with_nonce!(cipher, 32, "AEGIS-256X2"),
|
|
Cipher::Aegis256X4(cipher) => decrypt_with_nonce!(cipher, 32, "AEGIS-256X4"),
|
|
Cipher::Aegis128L(cipher) => decrypt_with_nonce!(cipher, 16, "AEGIS-128L"),
|
|
Cipher::Aegis128X2(cipher) => decrypt_with_nonce!(cipher, 16, "AEGIS-128X2"),
|
|
Cipher::Aegis128X4(cipher) => decrypt_with_nonce!(cipher, 16, "AEGIS-128X4"),
|
|
}
|
|
}
|
|
|
|
#[cfg(not(feature = "encryption"))]
|
|
pub fn encrypt_page(&self, _page: &[u8], _page_id: usize) -> Result<Vec<u8>> {
|
|
Err(LimboError::InvalidArgument(
|
|
"encryption is not enabled, cannot encrypt page. enable via passing `--features encryption`".into(),
|
|
))
|
|
}
|
|
|
|
#[cfg(not(feature = "encryption"))]
|
|
pub fn decrypt_page(&self, _encrypted_page: &[u8], _page_id: usize) -> Result<Vec<u8>> {
|
|
Err(LimboError::InvalidArgument(
|
|
"encryption is not enabled, cannot decrypt page. enable via passing `--features encryption`".into(),
|
|
))
|
|
}
|
|
}
|
|
|
|
fn generate_secure_nonce<const N: usize>() -> [u8; N] {
|
|
// use OsRng directly to fill bytes, generic over nonce size
|
|
use aes_gcm::aead::rand_core::RngCore;
|
|
let mut nonce = [0u8; N];
|
|
OsRng.fill_bytes(&mut nonce);
|
|
nonce
|
|
}
|
|
|
|
// Helper functions for consistent error messages
|
|
enum CipherError {
|
|
InvalidKeySize {
|
|
cipher: &'static str,
|
|
expected: usize,
|
|
},
|
|
InvalidTagSize {
|
|
cipher: &'static str,
|
|
},
|
|
DecryptionFailed {
|
|
cipher: &'static str,
|
|
},
|
|
CiphertextTooShort {
|
|
cipher: &'static str,
|
|
},
|
|
}
|
|
|
|
impl From<CipherError> for LimboError {
|
|
fn from(err: CipherError) -> Self {
|
|
let msg = match err {
|
|
CipherError::InvalidKeySize { cipher, expected } => {
|
|
format!("{cipher} requires {expected}-byte key")
|
|
}
|
|
CipherError::InvalidTagSize { cipher } => format!("Invalid tag size for {cipher}"),
|
|
CipherError::DecryptionFailed { cipher } => {
|
|
format!("{cipher} decryption failed: invalid tag")
|
|
}
|
|
CipherError::CiphertextTooShort { cipher } => {
|
|
format!("Ciphertext too short for {cipher}")
|
|
}
|
|
};
|
|
LimboError::InternalError(msg)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[cfg(feature = "encryption")]
|
|
mod tests {
|
|
use super::*;
|
|
use rand::Rng;
|
|
const DEFAULT_ENCRYPTED_PAGE_SIZE: usize = 4096;
|
|
|
|
macro_rules! test_cipher_wrapper {
|
|
($test_name:ident, $cipher_type:ty, $key_gen:expr, $nonce_size:literal, $message:literal) => {
|
|
#[test]
|
|
fn $test_name() {
|
|
let key = EncryptionKey::from_hex_string(&$key_gen()).unwrap();
|
|
let cipher = <$cipher_type>::new(&key);
|
|
|
|
let plaintext = $message.as_bytes();
|
|
let ad = b"additional data";
|
|
|
|
let (ciphertext, nonce) = cipher.encrypt(plaintext, ad).unwrap();
|
|
assert_eq!(nonce.len(), $nonce_size);
|
|
assert_ne!(ciphertext[..plaintext.len()], plaintext[..]);
|
|
|
|
let decrypted = cipher.decrypt(&ciphertext, &nonce, ad).unwrap();
|
|
assert_eq!(decrypted, plaintext);
|
|
}
|
|
};
|
|
}
|
|
|
|
macro_rules! test_aes_cipher_wrapper {
|
|
($test_name:ident, $cipher_type:ty, $key_gen:expr, $nonce_size:literal, $message:literal) => {
|
|
#[test]
|
|
fn $test_name() {
|
|
let key = EncryptionKey::from_hex_string(&$key_gen()).unwrap();
|
|
let cipher = <$cipher_type>::new(&key).unwrap();
|
|
|
|
let plaintext = $message.as_bytes();
|
|
let ad = b"additional data";
|
|
|
|
let (ciphertext, nonce) = cipher.encrypt(plaintext, ad).unwrap();
|
|
assert_eq!(nonce.len(), $nonce_size);
|
|
assert_ne!(ciphertext[..plaintext.len()], plaintext[..]);
|
|
|
|
let decrypted = cipher.decrypt(&ciphertext, &nonce, ad).unwrap();
|
|
assert_eq!(decrypted, plaintext);
|
|
}
|
|
};
|
|
}
|
|
|
|
macro_rules! test_raw_encryption {
|
|
($test_name:ident, $cipher_mode:expr, $key_gen:expr, $nonce_size:literal, $message:literal) => {
|
|
#[test]
|
|
fn $test_name() {
|
|
let key = EncryptionKey::from_hex_string(&$key_gen()).unwrap();
|
|
let ctx = EncryptionContext::new($cipher_mode, &key, DEFAULT_ENCRYPTED_PAGE_SIZE)
|
|
.unwrap();
|
|
|
|
let plaintext = $message.as_bytes();
|
|
let (ciphertext, nonce) = ctx.encrypt_raw(plaintext).unwrap();
|
|
|
|
assert_eq!(nonce.len(), $nonce_size);
|
|
assert_ne!(ciphertext[..plaintext.len()], plaintext[..]);
|
|
|
|
let decrypted = ctx.decrypt_raw(&ciphertext, &nonce).unwrap();
|
|
assert_eq!(decrypted, plaintext);
|
|
}
|
|
};
|
|
}
|
|
|
|
fn generate_random_hex_key() -> String {
|
|
let mut rng = rand::rng();
|
|
let mut bytes = [0u8; 32];
|
|
rng.fill(&mut bytes);
|
|
hex::encode(bytes)
|
|
}
|
|
|
|
fn generate_random_hex_key_128() -> String {
|
|
let mut rng = rand::rng();
|
|
let mut bytes = [0u8; 16];
|
|
rng.fill(&mut bytes);
|
|
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::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,
|
|
generate_random_hex_key_128,
|
|
12,
|
|
"Hello, AES-128-GCM!"
|
|
);
|
|
|
|
test_raw_encryption!(
|
|
test_aes128gcm_raw_encryption,
|
|
CipherMode::Aes128Gcm,
|
|
generate_random_hex_key_128,
|
|
12,
|
|
"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::rng();
|
|
let cipher_mode = CipherMode::Aes128Gcm;
|
|
let metadata_size = cipher_mode.metadata_size();
|
|
let data_size = DEFAULT_ENCRYPTED_PAGE_SIZE - metadata_size;
|
|
|
|
let page_data = {
|
|
let mut page = vec![0u8; DEFAULT_ENCRYPTED_PAGE_SIZE];
|
|
page.iter_mut()
|
|
.take(data_size)
|
|
.for_each(|byte| *byte = rng.random());
|
|
page
|
|
};
|
|
|
|
let key = EncryptionKey::from_hex_string(&generate_random_hex_key_128()).unwrap();
|
|
let ctx = EncryptionContext::new(CipherMode::Aes128Gcm, &key, DEFAULT_ENCRYPTED_PAGE_SIZE)
|
|
.unwrap();
|
|
|
|
let page_id = 42;
|
|
let encrypted = ctx.encrypt_page(&page_data, page_id).unwrap();
|
|
assert_eq!(encrypted.len(), DEFAULT_ENCRYPTED_PAGE_SIZE);
|
|
assert_ne!(&encrypted[..data_size], &page_data[..data_size]);
|
|
assert_ne!(&encrypted[..], &page_data[..]);
|
|
|
|
let decrypted = ctx.decrypt_page(&encrypted, page_id).unwrap();
|
|
assert_eq!(decrypted.len(), DEFAULT_ENCRYPTED_PAGE_SIZE);
|
|
assert_eq!(decrypted, page_data);
|
|
}
|
|
|
|
#[test]
|
|
fn test_aes_encrypt_decrypt_round_trip() {
|
|
let mut rng = rand::rng();
|
|
let cipher_mode = CipherMode::Aes256Gcm;
|
|
let metadata_size = cipher_mode.metadata_size();
|
|
let data_size = DEFAULT_ENCRYPTED_PAGE_SIZE - metadata_size;
|
|
|
|
let page_data = {
|
|
let mut page = vec![0u8; DEFAULT_ENCRYPTED_PAGE_SIZE];
|
|
page.iter_mut()
|
|
.take(data_size)
|
|
.for_each(|byte| *byte = rng.random());
|
|
page
|
|
};
|
|
|
|
let key = EncryptionKey::from_hex_string(&generate_random_hex_key()).unwrap();
|
|
let ctx = EncryptionContext::new(CipherMode::Aes256Gcm, &key, DEFAULT_ENCRYPTED_PAGE_SIZE)
|
|
.unwrap();
|
|
|
|
let page_id = 42;
|
|
let encrypted = ctx.encrypt_page(&page_data, page_id).unwrap();
|
|
assert_eq!(encrypted.len(), DEFAULT_ENCRYPTED_PAGE_SIZE);
|
|
assert_ne!(&encrypted[..data_size], &page_data[..data_size]);
|
|
assert_ne!(&encrypted[..], &page_data[..]);
|
|
|
|
let decrypted = ctx.decrypt_page(&encrypted, page_id).unwrap();
|
|
assert_eq!(decrypted.len(), DEFAULT_ENCRYPTED_PAGE_SIZE);
|
|
assert_eq!(decrypted, page_data);
|
|
}
|
|
|
|
test_cipher_wrapper!(
|
|
test_aegis256_cipher_wrapper,
|
|
Aegis256Cipher,
|
|
generate_random_hex_key,
|
|
32,
|
|
"Hello, AEGIS-256!"
|
|
);
|
|
|
|
test_raw_encryption!(
|
|
test_aegis256_raw_encryption,
|
|
CipherMode::Aegis256,
|
|
generate_random_hex_key,
|
|
32,
|
|
"Hello, AEGIS-256!"
|
|
);
|
|
|
|
#[test]
|
|
fn test_aegis256_encrypt_decrypt_round_trip() {
|
|
let mut rng = rand::rng();
|
|
let cipher_mode = CipherMode::Aegis256;
|
|
let metadata_size = cipher_mode.metadata_size();
|
|
let data_size = DEFAULT_ENCRYPTED_PAGE_SIZE - metadata_size;
|
|
|
|
let page_data = {
|
|
let mut page = vec![0u8; DEFAULT_ENCRYPTED_PAGE_SIZE];
|
|
page.iter_mut()
|
|
.take(data_size)
|
|
.for_each(|byte| *byte = rng.random());
|
|
page
|
|
};
|
|
|
|
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_id = 42;
|
|
let encrypted = ctx.encrypt_page(&page_data, page_id).unwrap();
|
|
assert_eq!(encrypted.len(), DEFAULT_ENCRYPTED_PAGE_SIZE);
|
|
assert_ne!(&encrypted[..data_size], &page_data[..data_size]);
|
|
|
|
let decrypted = ctx.decrypt_page(&encrypted, page_id).unwrap();
|
|
assert_eq!(decrypted.len(), DEFAULT_ENCRYPTED_PAGE_SIZE);
|
|
assert_eq!(decrypted, page_data);
|
|
}
|
|
|
|
test_cipher_wrapper!(
|
|
test_aegis128x2_cipher_wrapper,
|
|
Aegis128X2Cipher,
|
|
generate_random_hex_key_128,
|
|
16,
|
|
"Hello, AEGIS-128X2!"
|
|
);
|
|
|
|
test_raw_encryption!(
|
|
test_aegis128x2_raw_encryption,
|
|
CipherMode::Aegis128X2,
|
|
generate_random_hex_key_128,
|
|
16,
|
|
"Hello, AEGIS-128X2!"
|
|
);
|
|
|
|
#[test]
|
|
fn test_aegis128x2_encrypt_decrypt_round_trip() {
|
|
let mut rng = rand::rng();
|
|
let cipher_mode = CipherMode::Aegis128X2;
|
|
let metadata_size = cipher_mode.metadata_size();
|
|
let data_size = DEFAULT_ENCRYPTED_PAGE_SIZE - metadata_size;
|
|
|
|
let page_data = {
|
|
let mut page = vec![0u8; DEFAULT_ENCRYPTED_PAGE_SIZE];
|
|
page.iter_mut()
|
|
.take(data_size)
|
|
.for_each(|byte| *byte = rng.random());
|
|
page
|
|
};
|
|
|
|
let key = EncryptionKey::from_hex_string(&generate_random_hex_key_128()).unwrap();
|
|
let ctx = EncryptionContext::new(CipherMode::Aegis128X2, &key, DEFAULT_ENCRYPTED_PAGE_SIZE)
|
|
.unwrap();
|
|
|
|
let page_id = 42;
|
|
let encrypted = ctx.encrypt_page(&page_data, page_id).unwrap();
|
|
assert_eq!(encrypted.len(), DEFAULT_ENCRYPTED_PAGE_SIZE);
|
|
assert_ne!(&encrypted[..data_size], &page_data[..data_size]);
|
|
|
|
let decrypted = ctx.decrypt_page(&encrypted, page_id).unwrap();
|
|
assert_eq!(decrypted.len(), DEFAULT_ENCRYPTED_PAGE_SIZE);
|
|
assert_eq!(decrypted, page_data);
|
|
}
|
|
|
|
test_cipher_wrapper!(
|
|
test_aegis128l_cipher_wrapper,
|
|
Aegis128LCipher,
|
|
generate_random_hex_key_128,
|
|
16,
|
|
"Hello, AEGIS-128L!"
|
|
);
|
|
|
|
test_raw_encryption!(
|
|
test_aegis128l_raw_encryption,
|
|
CipherMode::Aegis128L,
|
|
generate_random_hex_key_128,
|
|
16,
|
|
"Hello, AEGIS-128L!"
|
|
);
|
|
|
|
#[test]
|
|
fn test_aegis128l_encrypt_decrypt_round_trip() {
|
|
let mut rng = rand::rng();
|
|
let cipher_mode = CipherMode::Aegis128L;
|
|
let metadata_size = cipher_mode.metadata_size();
|
|
let data_size = DEFAULT_ENCRYPTED_PAGE_SIZE - metadata_size;
|
|
|
|
let page_data = {
|
|
let mut page = vec![0u8; DEFAULT_ENCRYPTED_PAGE_SIZE];
|
|
page.iter_mut()
|
|
.take(data_size)
|
|
.for_each(|byte| *byte = rng.random());
|
|
page
|
|
};
|
|
|
|
let key = EncryptionKey::from_hex_string(&generate_random_hex_key_128()).unwrap();
|
|
let ctx = EncryptionContext::new(CipherMode::Aegis128L, &key, DEFAULT_ENCRYPTED_PAGE_SIZE)
|
|
.unwrap();
|
|
|
|
let page_id = 42;
|
|
let encrypted = ctx.encrypt_page(&page_data, page_id).unwrap();
|
|
assert_eq!(encrypted.len(), DEFAULT_ENCRYPTED_PAGE_SIZE);
|
|
assert_ne!(&encrypted[..data_size], &page_data[..data_size]);
|
|
|
|
let decrypted = ctx.decrypt_page(&encrypted, page_id).unwrap();
|
|
assert_eq!(decrypted.len(), DEFAULT_ENCRYPTED_PAGE_SIZE);
|
|
assert_eq!(decrypted, page_data);
|
|
}
|
|
|
|
test_cipher_wrapper!(
|
|
test_aegis128x4_cipher_wrapper,
|
|
Aegis128X4Cipher,
|
|
generate_random_hex_key_128,
|
|
16,
|
|
"Hello, AEGIS-128X4!"
|
|
);
|
|
|
|
test_raw_encryption!(
|
|
test_aegis128x4_raw_encryption,
|
|
CipherMode::Aegis128X4,
|
|
generate_random_hex_key_128,
|
|
16,
|
|
"Hello, AEGIS-128X4!"
|
|
);
|
|
|
|
#[test]
|
|
fn test_aegis128x4_encrypt_decrypt_round_trip() {
|
|
let mut rng = rand::rng();
|
|
let cipher_mode = CipherMode::Aegis128X4;
|
|
let metadata_size = cipher_mode.metadata_size();
|
|
let data_size = DEFAULT_ENCRYPTED_PAGE_SIZE - metadata_size;
|
|
|
|
let page_data = {
|
|
let mut page = vec![0u8; DEFAULT_ENCRYPTED_PAGE_SIZE];
|
|
page.iter_mut()
|
|
.take(data_size)
|
|
.for_each(|byte| *byte = rng.random());
|
|
page
|
|
};
|
|
|
|
let key = EncryptionKey::from_hex_string(&generate_random_hex_key_128()).unwrap();
|
|
let ctx = EncryptionContext::new(CipherMode::Aegis128X4, &key, DEFAULT_ENCRYPTED_PAGE_SIZE)
|
|
.unwrap();
|
|
|
|
let page_id = 42;
|
|
let encrypted = ctx.encrypt_page(&page_data, page_id).unwrap();
|
|
assert_eq!(encrypted.len(), DEFAULT_ENCRYPTED_PAGE_SIZE);
|
|
assert_ne!(&encrypted[..data_size], &page_data[..data_size]);
|
|
|
|
let decrypted = ctx.decrypt_page(&encrypted, page_id).unwrap();
|
|
assert_eq!(decrypted.len(), DEFAULT_ENCRYPTED_PAGE_SIZE);
|
|
assert_eq!(decrypted, page_data);
|
|
}
|
|
|
|
test_cipher_wrapper!(
|
|
test_aegis256x2_cipher_wrapper,
|
|
Aegis256X2Cipher,
|
|
generate_random_hex_key,
|
|
32,
|
|
"Hello, AEGIS-256X2!"
|
|
);
|
|
|
|
test_raw_encryption!(
|
|
test_aegis256x2_raw_encryption,
|
|
CipherMode::Aegis256X2,
|
|
generate_random_hex_key,
|
|
32,
|
|
"Hello, AEGIS-256X2!"
|
|
);
|
|
|
|
#[test]
|
|
fn test_aegis256x2_encrypt_decrypt_round_trip() {
|
|
let mut rng = rand::rng();
|
|
let cipher_mode = CipherMode::Aegis256X2;
|
|
let metadata_size = cipher_mode.metadata_size();
|
|
let data_size = DEFAULT_ENCRYPTED_PAGE_SIZE - metadata_size;
|
|
|
|
let page_data = {
|
|
let mut page = vec![0u8; DEFAULT_ENCRYPTED_PAGE_SIZE];
|
|
page.iter_mut()
|
|
.take(data_size)
|
|
.for_each(|byte| *byte = rng.random());
|
|
page
|
|
};
|
|
|
|
let key = EncryptionKey::from_hex_string(&generate_random_hex_key()).unwrap();
|
|
let ctx = EncryptionContext::new(CipherMode::Aegis256X2, &key, DEFAULT_ENCRYPTED_PAGE_SIZE)
|
|
.unwrap();
|
|
|
|
let page_id = 42;
|
|
let encrypted = ctx.encrypt_page(&page_data, page_id).unwrap();
|
|
assert_eq!(encrypted.len(), DEFAULT_ENCRYPTED_PAGE_SIZE);
|
|
assert_ne!(&encrypted[..data_size], &page_data[..data_size]);
|
|
|
|
let decrypted = ctx.decrypt_page(&encrypted, page_id).unwrap();
|
|
assert_eq!(decrypted.len(), DEFAULT_ENCRYPTED_PAGE_SIZE);
|
|
assert_eq!(decrypted, page_data);
|
|
}
|
|
|
|
test_cipher_wrapper!(
|
|
test_aegis256x4_cipher_wrapper,
|
|
Aegis256X4Cipher,
|
|
generate_random_hex_key,
|
|
32,
|
|
"Hello, AEGIS-256X4!"
|
|
);
|
|
|
|
test_raw_encryption!(
|
|
test_aegis256x4_raw_encryption,
|
|
CipherMode::Aegis256X4,
|
|
generate_random_hex_key,
|
|
32,
|
|
"Hello, AEGIS-256X4!"
|
|
);
|
|
|
|
#[test]
|
|
fn test_aegis256x4_encrypt_decrypt_round_trip() {
|
|
let mut rng = rand::rng();
|
|
let cipher_mode = CipherMode::Aegis256X4;
|
|
let metadata_size = cipher_mode.metadata_size();
|
|
let data_size = DEFAULT_ENCRYPTED_PAGE_SIZE - metadata_size;
|
|
|
|
let page_data = {
|
|
let mut page = vec![0u8; DEFAULT_ENCRYPTED_PAGE_SIZE];
|
|
page.iter_mut()
|
|
.take(data_size)
|
|
.for_each(|byte| *byte = rng.random());
|
|
page
|
|
};
|
|
|
|
let key = EncryptionKey::from_hex_string(&generate_random_hex_key()).unwrap();
|
|
let ctx = EncryptionContext::new(CipherMode::Aegis256X4, &key, DEFAULT_ENCRYPTED_PAGE_SIZE)
|
|
.unwrap();
|
|
|
|
let page_id = 42;
|
|
let encrypted = ctx.encrypt_page(&page_data, page_id).unwrap();
|
|
assert_eq!(encrypted.len(), DEFAULT_ENCRYPTED_PAGE_SIZE);
|
|
assert_ne!(&encrypted[..data_size], &page_data[..data_size]);
|
|
|
|
let decrypted = ctx.decrypt_page(&encrypted, page_id).unwrap();
|
|
assert_eq!(decrypted.len(), DEFAULT_ENCRYPTED_PAGE_SIZE);
|
|
assert_eq!(decrypted, page_data);
|
|
}
|
|
|
|
#[test]
|
|
fn test_cipher_mode_string_parsing() {
|
|
// Test AES-128-GCM
|
|
let mode = CipherMode::try_from("aes128gcm").unwrap();
|
|
assert_eq!(mode, CipherMode::Aes128Gcm);
|
|
assert_eq!(mode.to_string(), "aes128gcm");
|
|
assert_eq!(mode.required_key_size(), 16);
|
|
assert_eq!(mode.nonce_size(), 12);
|
|
assert_eq!(mode.tag_size(), 16);
|
|
|
|
let mode = CipherMode::try_from("aes-128-gcm").unwrap();
|
|
assert_eq!(mode, CipherMode::Aes128Gcm);
|
|
|
|
let mode = CipherMode::try_from("aes_128_gcm").unwrap();
|
|
assert_eq!(mode, CipherMode::Aes128Gcm);
|
|
|
|
// Test AES-256-GCM
|
|
let mode = CipherMode::try_from("aes256gcm").unwrap();
|
|
assert_eq!(mode, CipherMode::Aes256Gcm);
|
|
assert_eq!(mode.to_string(), "aes256gcm");
|
|
assert_eq!(mode.required_key_size(), 32);
|
|
assert_eq!(mode.nonce_size(), 12);
|
|
|
|
// Test that all AEGIS variants can be parsed from strings
|
|
let mode = CipherMode::try_from("aegis128x2").unwrap();
|
|
assert_eq!(mode, CipherMode::Aegis128X2);
|
|
assert_eq!(mode.to_string(), "aegis128x2");
|
|
assert_eq!(mode.required_key_size(), 16);
|
|
assert_eq!(mode.nonce_size(), 16);
|
|
assert_eq!(mode.tag_size(), 16);
|
|
|
|
let mode = CipherMode::try_from("aegis-128x2").unwrap();
|
|
assert_eq!(mode, CipherMode::Aegis128X2);
|
|
|
|
let mode = CipherMode::try_from("aegis_128x2").unwrap();
|
|
assert_eq!(mode, CipherMode::Aegis128X2);
|
|
|
|
// Test AEGIS-128L
|
|
let mode = CipherMode::try_from("aegis128l").unwrap();
|
|
assert_eq!(mode, CipherMode::Aegis128L);
|
|
assert_eq!(mode.to_string(), "aegis128l");
|
|
assert_eq!(mode.required_key_size(), 16);
|
|
assert_eq!(mode.nonce_size(), 16);
|
|
|
|
// Test AEGIS-128X4
|
|
let mode = CipherMode::try_from("aegis128x4").unwrap();
|
|
assert_eq!(mode, CipherMode::Aegis128X4);
|
|
assert_eq!(mode.to_string(), "aegis128x4");
|
|
assert_eq!(mode.required_key_size(), 16);
|
|
assert_eq!(mode.nonce_size(), 16);
|
|
|
|
// Test AEGIS-256X2
|
|
let mode = CipherMode::try_from("aegis256x2").unwrap();
|
|
assert_eq!(mode, CipherMode::Aegis256X2);
|
|
assert_eq!(mode.to_string(), "aegis256x2");
|
|
assert_eq!(mode.required_key_size(), 32);
|
|
assert_eq!(mode.nonce_size(), 32);
|
|
|
|
// Test AEGIS-256X4
|
|
let mode = CipherMode::try_from("aegis256x4").unwrap();
|
|
assert_eq!(mode, CipherMode::Aegis256X4);
|
|
assert_eq!(mode.to_string(), "aegis256x4");
|
|
assert_eq!(mode.required_key_size(), 32);
|
|
assert_eq!(mode.nonce_size(), 32);
|
|
}
|
|
}
|