use crate::agent::messages::Message;
use anyhow::Result;
use async_trait::async_trait;
use cryptovault::CryptoVault;
use magi_rs::vault::{bootstrap_envelope, open_envelope, MaskedDek, VaultError};
use rusqlite::{params, Connection, OptionalExtension};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use zeroize::Zeroizing;
const BUSY_TIMEOUT_SECS: u64 = 5;
const DATA_TABLES: [&str; 4] = ["sessions", "messages", "knowledge", "memories"];
const DETAIL_DATA_WITHOUT_ENVELOPE: &str = "data present without envelope";
#[async_trait]
pub trait MemoryStore: Send + Sync {
async fn create_session(&self, project_name: &str) -> Result<String>;
async fn add_message(&self, session_id: &str, message: &Message) -> Result<()>;
async fn get_messages(&self, session_id: &str) -> Result<Vec<Message>>;
async fn list_sessions(&self) -> Result<Vec<(String, String)>>;
async fn set_knowledge(&self, key: &str, value: &str) -> Result<()>;
async fn get_knowledge(&self, key: &str) -> Result<Option<String>>;
async fn list_knowledge_keys(&self) -> Result<Vec<String>>;
}
pub struct EncryptedSqliteMemory {
conn: Arc<Mutex<Connection>>,
vault: CryptoVault,
dek: std::sync::Mutex<MaskedDek>,
}
impl EncryptedSqliteMemory {
fn locked_conn(&self) -> std::sync::MutexGuard<'_, Connection> {
self.conn.lock().unwrap_or_else(|poisoned| {
use std::sync::atomic::{AtomicBool, Ordering};
static POISON_WARNED: AtomicBool = AtomicBool::new(false);
if !POISON_WARNED.swap(true, Ordering::Relaxed) {
eprintln!(
"WARNING: database connection mutex was poisoned by a panic in another \
thread; recovering the connection and continuing (further occurrences \
suppressed)."
);
}
poisoned.into_inner()
})
}
fn collect_message_rows(&self, session_id: &str) -> Result<Vec<(String, String)>> {
let conn = self.locked_conn();
let mut stmt = conn.prepare(
"SELECT role, content_blob FROM messages WHERE session_id = ? ORDER BY created_at ASC",
)?;
let mapped = stmt.query_map(params![session_id], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})?;
let mut collected = Vec::new();
for row in mapped {
collected.push(row?);
}
Ok(collected)
}
fn decrypt_rows(&self, rows: Vec<(String, String)>) -> Result<Vec<Message>> {
let mut messages = Vec::with_capacity(rows.len());
for (role_str, blob) in rows {
let decrypted = self.unseal(&blob)?;
let content = serde_json::from_str(decrypted.as_str())?;
let role = match role_str.as_str() {
"User" => crate::agent::messages::Role::User,
_ => crate::agent::messages::Role::Assistant,
};
messages.push(Message { role, content });
}
Ok(messages)
}
pub fn new(path: PathBuf, master_password: Zeroizing<String>) -> Result<Self> {
Self::new_with_vault(path, master_password, CryptoVault::default())
}
pub(crate) fn new_with_vault(
path: PathBuf,
master_password: Zeroizing<String>,
vault: CryptoVault,
) -> Result<Self> {
let mut conn = open_connection(&path).map_err(map_open_err)?;
init_schema(&conn)?;
let derived_key = open_or_bootstrap(&mut conn, &vault, master_password.as_str(), &path)
.map_err(map_open_err)?;
Ok(Self {
conn: Arc::new(Mutex::new(conn)),
vault,
dek: std::sync::Mutex::new(MaskedDek::new(derived_key)?),
})
}
pub(crate) fn open_with_state_machine(
path: PathBuf,
master_password: Zeroizing<String>,
) -> std::result::Result<Self, VaultError> {
Self::open_with_state_machine_vault(path, master_password, CryptoVault::default())
}
pub(crate) fn open_with_state_machine_vault(
path: PathBuf,
master_password: Zeroizing<String>,
vault: CryptoVault,
) -> std::result::Result<Self, VaultError> {
let mut conn = open_connection(&path)?;
let derived_key = open_or_bootstrap(&mut conn, &vault, master_password.as_str(), &path)?;
Ok(Self {
conn: Arc::new(Mutex::new(conn)),
vault,
dek: std::sync::Mutex::new(MaskedDek::new(derived_key)?),
})
}
fn seal(&self, plaintext: &str) -> Result<String> {
let mut dek = self.dek.lock().unwrap_or_else(|p| p.into_inner());
dek.with_dek(|k| self.vault.encrypt_with_key(k, plaintext))
.map_err(|e| anyhow::anyhow!("Encryption failed: {e}"))
}
fn unseal(&self, blob: &str) -> Result<Zeroizing<String>> {
let mut dek = self.dek.lock().unwrap_or_else(|p| p.into_inner());
dek.with_dek(|k| self.vault.decrypt_with_key(k, blob))
.map_err(|e| anyhow::anyhow!("Decryption failed: {e}"))
}
}
fn map_open_err(e: VaultError) -> anyhow::Error {
e.into()
}
fn open_connection(path: &Path) -> std::result::Result<Connection, VaultError> {
let conn = Connection::open(path).map_err(|e| VaultError::Storage(e.to_string()))?;
conn.busy_timeout(std::time::Duration::from_secs(BUSY_TIMEOUT_SECS))
.map_err(|e| VaultError::Storage(e.to_string()))?;
let _: String = conn
.query_row("PRAGMA journal_mode = WAL", [], |row| row.get(0))
.map_err(|e| VaultError::Storage(e.to_string()))?;
conn.execute("PRAGMA synchronous = NORMAL", [])
.map_err(|e| VaultError::Storage(e.to_string()))?;
Ok(conn)
}
fn map_table_err(e: rusqlite::Error, table: &str, db_path: &Path) -> VaultError {
if e.to_string().contains("no such table") {
VaultError::DbCorrupt {
db_path: db_path.to_path_buf(),
detail: format!("missing table `{table}`"),
}
} else {
VaultError::Storage(e.to_string())
}
}
fn count_rows(
conn: &Connection,
table: &str,
db_path: &Path,
) -> std::result::Result<i64, VaultError> {
conn.query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |r| r.get(0))
.map_err(|e| map_table_err(e, table, db_path))
}
fn read_wrapped_dek(
conn: &Connection,
db_path: &Path,
) -> std::result::Result<Option<Vec<u8>>, VaultError> {
conn.query_row(
"SELECT value FROM vault_meta WHERE key = 'wrapped_dek'",
[],
|r| r.get(0),
)
.optional()
.map_err(|e| map_table_err(e, "vault_meta", db_path))
}
fn open_or_bootstrap(
conn: &mut Connection,
vault: &CryptoVault,
password: &str,
db_path: &Path,
) -> std::result::Result<Zeroizing<Vec<u8>>, VaultError> {
match read_wrapped_dek(conn, db_path)? {
Some(wrapped_fec) => open_existing_envelope(conn, vault, password, &wrapped_fec, db_path),
None => bootstrap_fresh_envelope(conn, vault, password, db_path),
}
}
fn open_existing_envelope(
conn: &Connection,
vault: &CryptoVault,
password: &str,
wrapped_fec: &[u8],
db_path: &Path,
) -> std::result::Result<Zeroizing<Vec<u8>>, VaultError> {
let salt_fec: Vec<u8> = conn
.query_row("SELECT value FROM vault_meta WHERE key = 'salt'", [], |r| {
r.get(0)
})
.optional()
.map_err(|e| map_table_err(e, "vault_meta", db_path))?
.ok_or(VaultError::VaultMetaCorrupt)?;
open_envelope(vault, password, &salt_fec, wrapped_fec)
}
fn bootstrap_fresh_envelope(
conn: &mut Connection,
vault: &CryptoVault,
password: &str,
db_path: &Path,
) -> std::result::Result<Zeroizing<Vec<u8>>, VaultError> {
let mut total: i64 = 0;
for table in DATA_TABLES {
total = total
.checked_add(count_rows(conn, table, db_path)?)
.ok_or_else(|| VaultError::Storage("row-count overflow".to_string()))?;
}
if total > 0 {
return Err(VaultError::DbCorrupt {
db_path: db_path.to_path_buf(),
detail: DETAIL_DATA_WITHOUT_ENVELOPE.to_string(),
});
}
let (salt_mine, wrapped_mine, dek_mine) = bootstrap_envelope(vault, password)?;
let tx = conn
.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)
.map_err(|e| VaultError::Storage(e.to_string()))?;
let raced: Option<Vec<u8>> = tx
.query_row(
"SELECT value FROM vault_meta WHERE key = 'wrapped_dek'",
[],
|r| r.get(0),
)
.optional()
.map_err(|e| VaultError::Storage(e.to_string()))?;
let adopted: Option<(Vec<u8>, Vec<u8>)> = match raced {
Some(wrapped_fec) => {
let salt_fec: Vec<u8> = tx
.query_row("SELECT value FROM vault_meta WHERE key = 'salt'", [], |r| {
r.get(0)
})
.map_err(|e| VaultError::Storage(e.to_string()))?;
Some((salt_fec, wrapped_fec))
}
None => {
tx.execute(
"INSERT OR REPLACE INTO vault_meta (key, value) VALUES ('salt', ?1)",
params![salt_mine],
)
.map_err(|e| VaultError::Storage(e.to_string()))?;
tx.execute(
"INSERT OR REPLACE INTO vault_meta (key, value) VALUES ('wrapped_dek', ?1)",
params![wrapped_mine],
)
.map_err(|e| VaultError::Storage(e.to_string()))?;
None
}
};
tx.commit()
.map_err(|e| VaultError::Storage(e.to_string()))?;
match adopted {
Some((salt_fec, wrapped_fec)) => open_envelope(vault, password, &salt_fec, &wrapped_fec),
None => Ok(dek_mine),
}
}
pub(crate) fn init_schema(conn: &Connection) -> rusqlite::Result<()> {
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
project_name TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
role TEXT NOT NULL,
content_blob TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(session_id) REFERENCES sessions(id)
);
CREATE TABLE IF NOT EXISTS knowledge (
key TEXT PRIMARY KEY,
value_blob TEXT NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS vault_meta (
key TEXT PRIMARY KEY,
value BLOB NOT NULL
);
CREATE TABLE IF NOT EXISTS memories (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
kind TEXT NOT NULL,
text_blob TEXT NOT NULL,
embedding_blob TEXT NOT NULL,
model_id TEXT NOT NULL,
dim INTEGER NOT NULL,
created_at INTEGER NOT NULL,
salience REAL NOT NULL,
access_count INTEGER NOT NULL DEFAULT 0,
last_accessed_at INTEGER NOT NULL,
superseded_by TEXT,
evicted_at INTEGER,
scope TEXT NOT NULL DEFAULT 'root',
distilled_at INTEGER
);
CREATE INDEX IF NOT EXISTS idx_memories_scope ON memories(scope);",
)
}
#[async_trait]
impl MemoryStore for EncryptedSqliteMemory {
async fn create_session(&self, project_name: &str) -> Result<String> {
let id = uuid::Uuid::new_v4().to_string();
let conn = self.locked_conn();
conn.execute(
"INSERT INTO sessions (id, project_name) VALUES (?1, ?2)",
params![id, project_name],
)?;
Ok(id)
}
async fn add_message(&self, session_id: &str, message: &Message) -> Result<()> {
let json_content = serde_json::to_string(&message.content)?;
let encrypted = self.seal(&json_content)?;
let conn = self.locked_conn();
conn.execute(
"INSERT INTO messages (session_id, role, content_blob) VALUES (?1, ?2, ?3)",
params![session_id, format!("{:?}", message.role), encrypted],
)?;
Ok(())
}
async fn get_messages(&self, session_id: &str) -> Result<Vec<Message>> {
let raw_rows = self.collect_message_rows(session_id)?;
self.decrypt_rows(raw_rows)
}
async fn list_sessions(&self) -> Result<Vec<(String, String)>> {
let conn = self.locked_conn();
let mut stmt =
conn.prepare("SELECT id, project_name FROM sessions ORDER BY created_at DESC")?;
let rows = stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?;
let mut sessions = Vec::new();
for row in rows {
sessions.push(row?);
}
Ok(sessions)
}
async fn set_knowledge(&self, key: &str, value: &str) -> Result<()> {
let encrypted = self.seal(value)?;
let conn = self.locked_conn();
conn.execute(
"INSERT OR REPLACE INTO knowledge (key, value_blob, updated_at) VALUES (?1, ?2, CURRENT_TIMESTAMP)",
params![key, encrypted],
)?;
Ok(())
}
async fn get_knowledge(&self, key: &str) -> Result<Option<String>> {
let blob: Option<String> = {
let conn = self.locked_conn();
let mut stmt = conn.prepare("SELECT value_blob FROM knowledge WHERE key = ?")?;
stmt.query_row(params![key], |row| row.get::<_, String>(0))
.optional()?
};
match blob {
Some(blob) => {
let decrypted = self.unseal(&blob)?;
Ok(Some(decrypted.as_str().to_owned()))
}
None => Ok(None),
}
}
async fn list_knowledge_keys(&self) -> Result<Vec<String>> {
let conn = self.locked_conn();
let mut stmt = conn.prepare("SELECT key FROM knowledge ORDER BY key ASC")?;
let rows = stmt.query_map([], |row| row.get(0))?;
let mut keys = Vec::new();
for row in rows {
keys.push(row?);
}
Ok(keys)
}
}
impl EncryptedSqliteMemory {
#[allow(dead_code)]
pub(crate) fn shared_conn(&self) -> Arc<Mutex<Connection>> {
self.conn.clone()
}
pub(crate) fn data_key(&self) -> std::result::Result<MaskedDek, VaultError> {
self.dek
.lock()
.unwrap_or_else(|p| p.into_inner())
.duplicate()
}
}
#[cfg(test)]
impl EncryptedSqliteMemory {
pub(crate) fn conn_for_test(&self) -> &Arc<Mutex<Connection>> {
&self.conn
}
pub(crate) fn collect_message_rows_for_test(
&self,
session_id: &str,
) -> Result<Vec<(String, String)>> {
self.collect_message_rows(session_id)
}
pub(crate) fn data_key_for_test(&self) -> MaskedDek {
self.data_key().expect("data_key")
}
}
#[cfg(test)]
mod tests {
use super::*;
use cryptovault::cipher::Aes256GcmSivCipher;
use cryptovault::fec::ConcatenatedFec;
use cryptovault::kdf::{Argon2Kdf, KeyDerivation};
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::{Arc, Barrier};
use tempfile::NamedTempFile;
fn test_master() -> Zeroizing<String> {
Zeroizing::new("state-machine-test-master-key".to_string())
}
fn row_count(conn: &Connection, table: &str) -> i64 {
conn.query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |r| r.get(0))
.unwrap()
}
fn seed_db_with_messages_no_envelope() -> (NamedTempFile, Connection, PathBuf) {
let tmp = NamedTempFile::new().unwrap();
let path = tmp.path().to_path_buf();
let conn = Connection::open(&path).unwrap();
init_schema(&conn).unwrap();
conn.execute(
"INSERT INTO sessions (id, project_name) VALUES ('s', 'p')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO messages (session_id, role, content_blob) VALUES ('s', 'User', 'ciphertext')",
[],
)
.unwrap();
(tmp, conn, path)
}
struct LockProbeKdf {
inner: FastKdf,
db_path: PathBuf,
lock_free_at_derivation: Arc<AtomicBool>,
}
impl KeyDerivation for LockProbeKdf {
fn derive_master(
&self,
password: &[u8],
salt: &[u8],
) -> cryptovault::Result<Zeroizing<Vec<u8>>> {
let mut probe = Connection::open(&self.db_path).unwrap();
probe
.busy_timeout(std::time::Duration::from_millis(200))
.unwrap();
let got_lock = probe
.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)
.is_ok();
self.lock_free_at_derivation
.store(got_lock, Ordering::SeqCst);
self.inner.derive_master(password, salt)
}
}
struct CountingKdf {
inner: Argon2Kdf,
calls: Arc<AtomicUsize>,
}
impl KeyDerivation for CountingKdf {
fn derive_master(
&self,
password: &[u8],
salt: &[u8],
) -> cryptovault::Result<Zeroizing<Vec<u8>>> {
self.calls.fetch_add(1, Ordering::SeqCst);
self.inner.derive_master(password, salt)
}
}
struct FastKdf;
impl KeyDerivation for FastKdf {
fn derive_master(
&self,
password: &[u8],
salt: &[u8],
) -> cryptovault::Result<Zeroizing<Vec<u8>>> {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(password);
hasher.update(salt);
Ok(Zeroizing::new(hasher.finalize().to_vec()))
}
}
fn fast_kdf_vault() -> CryptoVault {
CryptoVault::new(
Box::new(FastKdf),
Box::new(Aes256GcmSivCipher),
Box::new(ConcatenatedFec::default()),
)
}
#[test]
fn test_init_schema_creates_exactly_the_guarded_data_tables() {
let conn = Connection::open_in_memory().unwrap();
init_schema(&conn).unwrap();
let mut stmt = conn
.prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
.unwrap();
let created: std::collections::BTreeSet<String> = stmt
.query_map([], |r| r.get::<_, String>(0))
.unwrap()
.map(|r| r.unwrap())
.filter(|name| !name.starts_with("sqlite_"))
.collect();
let mut expected: std::collections::BTreeSet<String> =
DATA_TABLES.iter().map(|t| (*t).to_string()).collect();
expected.insert("vault_meta".to_string());
assert_eq!(
created, expected,
"init_schema must create exactly the DATA_TABLES plus vault_meta; a drift \
between DATA_TABLES and init_schema is a silent never-delete weakening"
);
for table in DATA_TABLES {
assert!(
created.contains(table),
"DATA_TABLES entry `{table}` must be created by init_schema"
);
}
assert!(
!DATA_TABLES.contains(&"vault_meta"),
"vault_meta is the envelope, not user data — it must not be a DATA_TABLE"
);
}
#[tokio::test]
async fn test_key_is_derived_exactly_once_for_session_load() {
let tmp = NamedTempFile::new().unwrap();
let calls = Arc::new(AtomicUsize::new(0));
let vault = CryptoVault::new(
Box::new(CountingKdf {
inner: Argon2Kdf,
calls: calls.clone(),
}),
Box::new(Aes256GcmSivCipher),
Box::new(ConcatenatedFec::default()),
);
let memory = EncryptedSqliteMemory::new_with_vault(
tmp.path().to_path_buf(),
Zeroizing::new("pw".to_string()),
vault,
)
.unwrap();
let sid = memory.create_session("p").await.unwrap();
for i in 0..5 {
memory
.add_message(&sid, &Message::user(&format!("m{i}")))
.await
.unwrap();
}
let msgs = memory.get_messages(&sid).await.unwrap();
assert_eq!(msgs.len(), 5);
assert_eq!(
calls.load(Ordering::SeqCst),
1,
"Argon2 must run exactly once (envelope KEK derivation), not per record"
);
}
#[tokio::test]
async fn test_legacy_db_without_salt_is_dbcorrupt_and_never_wiped() {
let tmp = NamedTempFile::new().unwrap();
let path = tmp.path().to_path_buf();
{
let conn = Connection::open(&path).unwrap();
conn.execute(
"CREATE TABLE sessions (id TEXT PRIMARY KEY, project_name TEXT NOT NULL, \
created_at DATETIME DEFAULT CURRENT_TIMESTAMP)",
[],
)
.unwrap();
conn.execute(
"CREATE TABLE messages (id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL, \
role TEXT NOT NULL, content_blob TEXT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP)",
[],
)
.unwrap();
conn.execute(
"INSERT INTO sessions (id, project_name) VALUES ('old', 'legacy')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO messages (session_id, role, content_blob) VALUES ('old', 'User', 'OLD_BLOB')",
[],
)
.unwrap();
}
let err = EncryptedSqliteMemory::new(path.clone(), Zeroizing::new("pw".to_string()))
.err()
.expect("data without an envelope must fail to open, not be wiped");
assert!(
matches!(
err.downcast_ref::<VaultError>(),
Some(VaultError::DbCorrupt { .. })
),
"expected DbCorrupt, got {err:?}"
);
let reopened = Connection::open(&path).unwrap();
assert_eq!(
row_count(&reopened, "sessions"),
1,
"never-delete: the legacy session row must survive the failed open"
);
assert_eq!(
row_count(&reopened, "messages"),
1,
"never-delete: the legacy message row must survive the failed open"
);
}
#[test]
fn test_open_without_envelope_but_with_data_is_dbcorrupt_never_wipes() {
let (_tmp, conn, path) = seed_db_with_messages_no_envelope();
let before = row_count(&conn, "messages");
assert!(before > 0, "the seed must actually contain data");
let err = EncryptedSqliteMemory::open_with_state_machine(path.clone(), test_master())
.err()
.expect("data without an envelope must be DbCorrupt");
assert!(
matches!(err, VaultError::DbCorrupt { .. }),
"expected DbCorrupt, got {err:?}"
);
let reopened = Connection::open(&path).unwrap();
let after = row_count(&reopened, "messages");
assert_eq!(
before, after,
"never-delete: the state machine must not delete any row"
);
}
#[tokio::test]
async fn test_open_without_envelope_and_empty_bootstraps_cleanly() {
let tmp = NamedTempFile::new().unwrap();
let path = tmp.path().to_path_buf();
{
let conn = Connection::open(&path).unwrap();
init_schema(&conn).unwrap();
}
let store = EncryptedSqliteMemory::open_with_state_machine_vault(
path.clone(),
test_master(),
fast_kdf_vault(),
)
.expect("an empty initialized DB must bootstrap cleanly");
let sid = store.create_session("p").await.unwrap();
store.add_message(&sid, &Message::user("hi")).await.unwrap();
assert_eq!(
store.get_messages(&sid).await.unwrap(),
vec![Message::user("hi")]
);
drop(store);
let reopened = EncryptedSqliteMemory::open_with_state_machine_vault(
path,
test_master(),
fast_kdf_vault(),
)
.expect("the bootstrapped envelope must reopen with the same passphrase");
assert_eq!(
reopened.get_messages(&sid).await.unwrap(),
vec![Message::user("hi")]
);
}
#[tokio::test]
async fn test_wrong_passphrase_via_state_machine_is_wrong_passphrase_and_intact() {
let tmp = NamedTempFile::new().unwrap();
let path = tmp.path().to_path_buf();
{
let conn = Connection::open(&path).unwrap();
init_schema(&conn).unwrap();
}
let right = || Zeroizing::new("right-master-alpha".to_string());
let wrong = || Zeroizing::new("wrong-master-bravo".to_string());
let sid;
{
let store = EncryptedSqliteMemory::open_with_state_machine_vault(
path.clone(),
right(),
fast_kdf_vault(),
)
.unwrap();
sid = store.create_session("p").await.unwrap();
store
.add_message(&sid, &Message::user("must survive"))
.await
.unwrap();
}
let err = EncryptedSqliteMemory::open_with_state_machine_vault(
path.clone(),
wrong(),
fast_kdf_vault(),
)
.err()
.expect("a wrong passphrase must fail to open");
assert!(
matches!(err, VaultError::WrongPassphrase),
"expected WrongPassphrase, got {err:?}"
);
let store =
EncryptedSqliteMemory::open_with_state_machine_vault(path, right(), fast_kdf_vault())
.expect("the correct passphrase must still open the untouched DB");
assert_eq!(
store.get_messages(&sid).await.unwrap(),
vec![Message::user("must survive")],
"the failed wrong-passphrase open must not have wiped the data"
);
}
#[test]
fn test_fec_damaged_vault_meta_is_vault_meta_corrupt_before_aead() {
let tmp = NamedTempFile::new().unwrap();
let path = tmp.path().to_path_buf();
{
let conn = Connection::open(&path).unwrap();
init_schema(&conn).unwrap();
}
EncryptedSqliteMemory::open_with_state_machine_vault(
path.clone(),
test_master(),
fast_kdf_vault(),
)
.unwrap();
{
let conn = Connection::open(&path).unwrap();
let mut blob: Vec<u8> = conn
.query_row(
"SELECT value FROM vault_meta WHERE key = 'wrapped_dek'",
[],
|r| r.get(0),
)
.unwrap();
for b in blob.iter_mut() {
*b ^= 0xFF;
}
conn.execute(
"UPDATE vault_meta SET value = ?1 WHERE key = 'wrapped_dek'",
params![blob],
)
.unwrap();
}
let err = EncryptedSqliteMemory::open_with_state_machine_vault(
path,
test_master(),
fast_kdf_vault(),
)
.err()
.expect("FEC-uncorrectable vault_meta must fail");
assert!(
matches!(err, VaultError::VaultMetaCorrupt),
"FEC damage must be VaultMetaCorrupt (before the AEAD), got {err:?}"
);
}
#[test]
fn test_concurrent_bootstrap_different_passphrase_loser_gets_wrong_passphrase() {
let tmp = NamedTempFile::new().unwrap();
let path = tmp.path().to_path_buf();
{
let seed = Connection::open(&path).unwrap();
let _: String = seed
.query_row("PRAGMA journal_mode = WAL", [], |r| r.get(0))
.unwrap();
init_schema(&seed).unwrap();
}
let barrier = Arc::new(Barrier::new(2));
let spawn_opener = |pass: &'static str| {
let path = path.clone();
let barrier = Arc::clone(&barrier);
std::thread::spawn(move || -> std::result::Result<(), &'static str> {
barrier.wait();
match EncryptedSqliteMemory::new_with_vault(
path,
Zeroizing::new(pass.to_string()),
fast_kdf_vault(),
) {
Ok(_) => Ok(()),
Err(e) => match e.downcast_ref::<VaultError>() {
Some(VaultError::WrongPassphrase) => Err("WrongPassphrase"),
_ => Err("other"),
},
}
})
};
let t1 = spawn_opener("passphrase-alpha-1234567");
let t2 = spawn_opener("passphrase-bravo-7654321");
let r1 = t1.join().expect("thread-a must not panic");
let r2 = t2.join().expect("thread-b must not panic");
let oks = [&r1, &r2].iter().filter(|r| r.is_ok()).count();
assert_eq!(
oks, 1,
"exactly one opener bootstraps the envelope; the other adopts it"
);
for r in [&r1, &r2] {
if let Err(kind) = r {
assert_eq!(
*kind, "WrongPassphrase",
"the loser adopts the winner's envelope and fails the AEAD tag"
);
}
}
}
#[test]
fn test_partial_schema_missing_table_is_dbcorrupt_and_intact() {
let tmp = NamedTempFile::new().unwrap();
let path = tmp.path().to_path_buf();
{
let conn = Connection::open(&path).unwrap();
init_schema(&conn).unwrap();
conn.execute("DROP TABLE messages", []).unwrap();
conn.execute(
"INSERT INTO sessions (id, project_name) VALUES ('s', 'p')",
[],
)
.unwrap();
}
let err = EncryptedSqliteMemory::open_with_state_machine(path.clone(), test_master())
.err()
.expect("a missing data table is corruption");
match err {
VaultError::DbCorrupt { ref detail, .. } => assert!(
detail.contains("messages"),
"detail must name the missing table, got {detail:?}"
),
other => panic!("expected DbCorrupt naming the table, got {other:?}"),
}
let reopened = Connection::open(&path).unwrap();
assert_eq!(
row_count(&reopened, "sessions"),
1,
"never-delete: a partial-schema corruption must not wipe surviving tables"
);
}
#[test]
fn test_kek_derivation_happens_before_the_bootstrap_write_lock() {
let tmp = NamedTempFile::new().unwrap();
let path = tmp.path().to_path_buf();
{
let conn = Connection::open(&path).unwrap();
init_schema(&conn).unwrap();
}
let lock_free = Arc::new(AtomicBool::new(false));
let vault = CryptoVault::new(
Box::new(LockProbeKdf {
inner: FastKdf,
db_path: path.clone(),
lock_free_at_derivation: lock_free.clone(),
}),
Box::new(Aes256GcmSivCipher),
Box::new(ConcatenatedFec::default()),
);
EncryptedSqliteMemory::open_with_state_machine_vault(path, test_master(), vault)
.expect("bootstrap must succeed");
assert!(
lock_free.load(Ordering::SeqCst),
"R-V08: the KEK derivation must run BEFORE the BEGIN IMMEDIATE write lock"
);
}
#[tokio::test]
async fn test_salt_persists_across_reopen_same_password_roundtrips() {
let tmp = NamedTempFile::new().unwrap();
let path = tmp.path().to_path_buf();
let sid;
{
let memory =
EncryptedSqliteMemory::new(path.clone(), Zeroizing::new("P".to_string())).unwrap();
sid = memory.create_session("p").await.unwrap();
memory
.add_message(&sid, &Message::user("persisted"))
.await
.unwrap();
}
{
let memory =
EncryptedSqliteMemory::new(path.clone(), Zeroizing::new("P".to_string())).unwrap();
assert_eq!(
memory.get_messages(&sid).await.unwrap(),
vec![Message::user("persisted")]
);
}
{
let res = EncryptedSqliteMemory::new(path, Zeroizing::new("P-different".to_string()));
assert!(res.is_err());
}
}
#[tokio::test]
async fn test_minor_salt_bitrot_is_corrected_and_history_survives() {
let tmp = NamedTempFile::new().unwrap();
let path = tmp.path().to_path_buf();
let sid;
{
let memory =
EncryptedSqliteMemory::new(path.clone(), Zeroizing::new("P".to_string())).unwrap();
sid = memory.create_session("p").await.unwrap();
memory
.add_message(&sid, &Message::user("survives"))
.await
.unwrap();
}
{
let conn = Connection::open(&path).unwrap();
let mut blob: Vec<u8> = conn
.query_row("SELECT value FROM vault_meta WHERE key = 'salt'", [], |r| {
r.get(0)
})
.unwrap();
let idx = 4.min(blob.len().saturating_sub(1));
blob[idx] ^= 0x01;
conn.execute(
"UPDATE vault_meta SET value = ?1 WHERE key = 'salt'",
params![blob],
)
.unwrap();
}
let memory = EncryptedSqliteMemory::new(path, Zeroizing::new("P".to_string())).unwrap();
assert_eq!(
memory.get_messages(&sid).await.unwrap(),
vec![Message::user("survives")],
"a single-bit flip within FEC capacity must self-correct, preserving history"
);
}
#[tokio::test]
async fn test_encrypted_sqlite_memory() {
let tmp_file = NamedTempFile::new().unwrap();
let path = tmp_file.path().to_path_buf();
let password = "master_key_123";
let memory =
EncryptedSqliteMemory::new(path, Zeroizing::new(password.to_string())).unwrap();
let sid = memory.create_session("test_proj").await.unwrap();
let msg = Message::user("Hello secure world");
memory.add_message(&sid, &msg).await.unwrap();
let msgs = memory.get_messages(&sid).await.unwrap();
assert_eq!(msgs.len(), 1);
assert_eq!(msgs[0], msg);
let conn = Connection::open(tmp_file.path()).unwrap();
let blob: String = conn
.query_row("SELECT content_blob FROM messages LIMIT 1", [], |r| {
r.get(0)
})
.unwrap();
assert!(
!blob.contains("Hello"),
"Database should contain encrypted blob, not plaintext"
);
let sessions = memory.list_sessions().await.unwrap();
assert_eq!(sessions.len(), 1);
assert_eq!(sessions[0].1, "test_proj");
}
#[tokio::test]
async fn test_project_knowledge_persistence() {
let tmp_file = NamedTempFile::new().unwrap();
let path = tmp_file.path().to_path_buf();
let password = "knowledge_key_123".to_string();
let memory = EncryptedSqliteMemory::new(path, Zeroizing::new(password)).unwrap();
memory
.set_knowledge("architecture", "Clean hex with encrypted SQLite")
.await
.unwrap();
let fact = memory.get_knowledge("architecture").await.unwrap();
assert_eq!(fact.unwrap(), "Clean hex with encrypted SQLite");
memory.set_knowledge("port", "54545").await.unwrap();
let keys = memory.list_knowledge_keys().await.unwrap();
assert_eq!(keys.len(), 2);
assert!(keys.contains(&"architecture".to_string()));
assert!(keys.contains(&"port".to_string()));
}
#[tokio::test]
async fn test_sqlite_concurrency_stress() {
let tmp_file = tempfile::NamedTempFile::new().unwrap();
let path = tmp_file.path().to_path_buf();
let memory = Arc::new(
EncryptedSqliteMemory::new(path, Zeroizing::new("stress_pass".to_string())).unwrap(),
);
let mut handles = vec![];
for i in 0..20 {
let mem_clone = memory.clone();
handles.push(tokio::spawn(async move {
let key = format!("key_{}", i);
let val = format!("val_{}", i);
mem_clone.set_knowledge(&key, &val).await
}));
}
for h in handles {
let res = h.await.unwrap();
assert!(res.is_ok(), "Concurrent write failed: {:?}", res.err());
}
let keys = memory.list_knowledge_keys().await.unwrap();
assert_eq!(keys.len(), 20);
}
#[tokio::test]
async fn test_poisoned_lock_recovers_and_continues() {
let tmp_file = NamedTempFile::new().unwrap();
let path = tmp_file.path().to_path_buf();
let memory = EncryptedSqliteMemory::new(path, Zeroizing::new("pw".to_string())).unwrap();
let conn = memory.conn_for_test().clone();
let _ = std::thread::spawn(move || {
let _guard = conn.lock().unwrap();
panic!("intentional poison");
})
.join();
assert!(
memory.list_sessions().await.is_ok(),
"a poisoned lock must be recovered, not fail closed"
);
let sid = memory.create_session("after-poison").await.unwrap();
assert!(!sid.is_empty());
assert_eq!(
memory.list_sessions().await.unwrap().len(),
1,
"persistence continues working after lock recovery"
);
}
#[tokio::test]
async fn test_get_messages_does_not_hold_lock_during_decrypt() {
let tmp_file = NamedTempFile::new().unwrap();
let path = tmp_file.path().to_path_buf();
let memory =
Arc::new(EncryptedSqliteMemory::new(path, Zeroizing::new("pw".to_string())).unwrap());
let sid = memory.create_session("p").await.unwrap();
for i in 0..4 {
memory
.add_message(&sid, &Message::user(&format!("message number {i}")))
.await
.unwrap();
}
let reader = {
let m = memory.clone();
let s = sid.clone();
tokio::spawn(async move { m.get_messages(&s).await })
};
let writer = {
let m = memory.clone();
tokio::spawn(async move { m.create_session("concurrent").await })
};
let msgs = reader.await.unwrap().unwrap();
let new_sid = writer.await.unwrap().unwrap();
assert_eq!(
msgs.len(),
4,
"all messages decrypt correctly after lock-drop refactor"
);
assert!(
!new_sid.is_empty(),
"a concurrent write completes; lock is not held across decrypt"
);
assert_eq!(msgs[0], Message::user("message number 0"));
}
#[tokio::test]
async fn test_get_knowledge_does_not_hold_lock_during_decrypt() {
let tmp_file = NamedTempFile::new().unwrap();
let path = tmp_file.path().to_path_buf();
let memory =
Arc::new(EncryptedSqliteMemory::new(path, Zeroizing::new("pw".to_string())).unwrap());
memory
.set_knowledge("api-endpoint", "value-42")
.await
.unwrap();
let reader = {
let m = memory.clone();
tokio::spawn(async move { m.get_knowledge("api-endpoint").await })
};
let writer = {
let m = memory.clone();
tokio::spawn(async move { m.create_session("concurrent").await })
};
let value = reader.await.unwrap().unwrap();
let new_sid = writer.await.unwrap().unwrap();
assert_eq!(
value.as_deref(),
Some("value-42"),
"the secret decrypts correctly after the lock-drop refactor"
);
assert!(
!new_sid.is_empty(),
"a concurrent write completes; the lock is not held across decrypt"
);
}
#[tokio::test]
async fn test_decrypt_rows_runs_without_connection_lock() {
let tmp_file = NamedTempFile::new().unwrap();
let memory = EncryptedSqliteMemory::new(
tmp_file.path().to_path_buf(),
Zeroizing::new("pw".to_string()),
)
.unwrap();
let sid = memory.create_session("p").await.unwrap();
memory
.add_message(&sid, &Message::user("hi"))
.await
.unwrap();
let raw = memory.collect_message_rows_for_test(&sid).unwrap();
let msgs = memory.decrypt_rows(raw).unwrap();
assert_eq!(msgs, vec![Message::user("hi")]);
}
#[tokio::test]
async fn test_derived_key_field_is_zeroizing_and_roundtrips() {
let tmp_file = NamedTempFile::new().unwrap();
let path = tmp_file.path().to_path_buf();
let memory =
EncryptedSqliteMemory::new(path, Zeroizing::new("zeroizing_pw".to_string())).unwrap();
let sid = memory.create_session("p").await.unwrap();
memory
.add_message(&sid, &Message::user("secret payload"))
.await
.unwrap();
let mut dek = memory.data_key_for_test();
assert_eq!(dek.with_dek(|k| k.len()), 32);
let msgs = memory.get_messages(&sid).await.unwrap();
assert_eq!(msgs, vec![Message::user("secret payload")]);
}
#[tokio::test]
async fn test_wrong_master_key_does_not_wipe_database() {
let tmp = NamedTempFile::new().unwrap();
let path = tmp.path().to_path_buf();
let sid;
{
let memory = EncryptedSqliteMemory::new(
path.clone(),
Zeroizing::new("correcto-master-key-string".to_string()),
)
.unwrap();
sid = memory.create_session("p").await.unwrap();
memory
.add_message(&sid, &Message::user("must survive"))
.await
.unwrap();
}
{
let res = EncryptedSqliteMemory::new(
path.clone(),
Zeroizing::new("wrong-master-key-string".to_string()),
);
assert!(
res.is_err(),
"a wrong master password must fail to open, not silently succeed"
);
}
{
let memory = EncryptedSqliteMemory::new(
path,
Zeroizing::new("correcto-master-key-string".to_string()),
)
.unwrap();
assert_eq!(
memory.get_messages(&sid).await.unwrap(),
vec![Message::user("must survive")],
"the failed wrong-master open attempt must not have wiped or \
corrupted the data"
);
}
}
#[test]
fn test_concurrent_bootstrap_on_fresh_db_yields_single_dek() {
let tmp = NamedTempFile::new().unwrap();
let path = tmp.path().to_path_buf();
{
let seed = Connection::open(&path).unwrap();
let _: String = seed
.query_row("PRAGMA journal_mode = WAL", [], |r| r.get(0))
.unwrap();
seed.execute_batch(
"CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
project_name TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
role TEXT NOT NULL,
content_blob TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(session_id) REFERENCES sessions(id)
);
CREATE TABLE IF NOT EXISTS knowledge (
key TEXT PRIMARY KEY,
value_blob TEXT NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS vault_meta (
key TEXT PRIMARY KEY,
value BLOB NOT NULL
);",
)
.unwrap();
}
let barrier = Arc::new(Barrier::new(2));
let spawn_opener = |label: &'static str| {
let path = path.clone();
let barrier = Arc::clone(&barrier);
std::thread::spawn(move || {
barrier.wait();
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
let memory = EncryptedSqliteMemory::new_with_vault(
path,
Zeroizing::new("shared-master".to_string()),
fast_kdf_vault(),
)
.expect("open must not fail under a concurrent bootstrap race");
let sid = memory.create_session(label).await.unwrap();
memory
.add_message(&sid, &Message::user(&format!("from {label}")))
.await
.unwrap();
});
})
};
let t1 = spawn_opener("thread-a");
let t2 = spawn_opener("thread-b");
t1.join().expect("thread-a must not panic");
t2.join().expect("thread-b must not panic");
let memory = EncryptedSqliteMemory::new_with_vault(
path,
Zeroizing::new("shared-master".to_string()),
fast_kdf_vault(),
)
.unwrap();
let rt = tokio::runtime::Runtime::new().unwrap();
let sessions = rt.block_on(memory.list_sessions()).unwrap();
assert_eq!(sessions.len(), 2, "both concurrent sessions were persisted");
let mut total_messages = 0;
for (sid, _project_name) in sessions {
let msgs = rt.block_on(memory.get_messages(&sid)).unwrap();
assert_eq!(
msgs.len(),
1,
"each session's message must decrypt under the shared DEK"
);
total_messages += msgs.len();
}
assert_eq!(total_messages, 2);
}
}