diff --git a/Cargo.lock b/Cargo.lock index bcdb99d..4cd52f0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -501,8 +501,11 @@ dependencies = [ "prost", "rand_core", "safer-ffi", + "storage", + "tempfile", "thiserror", "x25519-dalek", + "zeroize", ] [[package]] diff --git a/conversations/Cargo.toml b/conversations/Cargo.toml index cdb02a9..4ea9408 100644 --- a/conversations/Cargo.toml +++ b/conversations/Cargo.toml @@ -18,3 +18,8 @@ rand_core = { version = "0.6" } safer-ffi = "0.1.13" thiserror = "2.0.17" x25519-dalek = { version = "2.0.1", features = ["static_secrets", "reusable_secrets", "getrandom"] } +storage = { path = "../storage" } +zeroize = { version = "1.8.2", features = ["derive"] } + +[dev-dependencies] +tempfile = "3" diff --git a/conversations/src/api.rs b/conversations/src/api.rs index bd1e300..75319f9 100644 --- a/conversations/src/api.rs +++ b/conversations/src/api.rs @@ -13,9 +13,12 @@ use safer_ffi::{ prelude::{c_slice, repr_c}, }; +use storage::StorageConfig; + use crate::{ context::{Context, Introduction}, errors::ChatError, + ffi::utils::CResult, types::ContentData, }; @@ -54,6 +57,41 @@ pub fn create_context(name: repr_c::String) -> repr_c::Box { Box::new(ContextHandle(Context::new_with_name(&*name))).into() } +/// Creates a new libchat Context with file-based persistent storage. +/// +/// The identity will be loaded from storage if it exists, or created and saved if not. +/// +/// # Parameters +/// - name: Friendly name for the identity (used if creating new identity) +/// - db_path: Path to the SQLite database file +/// - db_secret: Secret key for encrypting the database +/// +/// # Returns +/// CResult with context handle on success, or error string on failure. +/// On success, the context handle must be freed with `destroy_context()` after usage. +/// On error, the error string must be freed with `destroy_string()` after usage. +#[ffi_export] +pub fn create_context_with_storage( + name: repr_c::String, + db_path: repr_c::String, + db_secret: repr_c::String, +) -> CResult, repr_c::String> { + let config = StorageConfig::Encrypted { + path: db_path.to_string(), + key: db_secret.to_string(), + }; + match Context::open(&*name, config) { + Ok(ctx) => CResult { + ok: Some(Box::new(ContextHandle(ctx)).into()), + err: None, + }, + Err(e) => CResult { + ok: None, + err: Some(e.to_string().into()), + }, + } +} + /// Returns the friendly name of the contexts installation. /// #[ffi_export] diff --git a/conversations/src/context.rs b/conversations/src/context.rs index 2b36f68..bec37a5 100644 --- a/conversations/src/context.rs +++ b/conversations/src/context.rs @@ -1,11 +1,14 @@ use std::rc::Rc; +use storage::StorageConfig; + use crate::{ conversation::{ConversationId, ConversationStore, Convo, Id}, errors::ChatError, identity::Identity, inbox::Inbox, proto::{EncryptedPayload, EnvelopeV1, Message}, + storage::ChatStorage, types::{AddressedEnvelope, ContentData}, }; @@ -18,17 +21,44 @@ pub struct Context { _identity: Rc, store: ConversationStore, inbox: Inbox, + #[allow(dead_code)] // Will be used for conversation persistence + storage: ChatStorage, } impl Context { - pub fn new_with_name(name: impl Into) -> Self { - let identity = Rc::new(Identity::new(name)); - let inbox = Inbox::new(Rc::clone(&identity)); // - Self { + /// Opens or creates a Context with the given storage configuration. + /// + /// If an identity exists in storage, it will be restored. + /// Otherwise, a new identity will be created with the given name and saved. + pub fn open(name: impl Into, config: StorageConfig) -> Result { + let mut storage = ChatStorage::new(config)?; + let name = name.into(); + + // Load or create identity + let identity = if let Some(identity) = storage.load_identity()? { + identity + } else { + let identity = Identity::new(&name); + storage.save_identity(&identity)?; + identity + }; + + let identity = Rc::new(identity); + let inbox = Inbox::new(Rc::clone(&identity)); + + Ok(Self { _identity: identity, store: ConversationStore::new(), inbox, - } + storage, + }) + } + + /// Creates a new in-memory Context (for testing). + /// + /// Uses in-memory SQLite database. Each call creates a new isolated database. + pub fn new_with_name(name: impl Into) -> Self { + Self::open(name, StorageConfig::InMemory).expect("in-memory storage should not fail") } pub fn installation_name(&self) -> &str { @@ -195,4 +225,31 @@ mod tests { send_and_verify(&mut saro, &mut raya, &saro_convo_id, &content); } } + + #[test] + fn identity_persistence() { + // Use file-based storage to test real persistence + let dir = tempfile::tempdir().unwrap(); + let db_path = dir + .path() + .join("test_identity.db") + .to_string_lossy() + .to_string(); + let config = StorageConfig::File(db_path); + + // Create context - this should create and save a new identity + let ctx1 = Context::open("alice", config.clone()).unwrap(); + let pubkey1 = ctx1._identity.public_key(); + let name1 = ctx1.installation_name().to_string(); + + // Drop and reopen - should load the same identity + drop(ctx1); + let ctx2 = Context::open("alice", config).unwrap(); + let pubkey2 = ctx2._identity.public_key(); + let name2 = ctx2.installation_name().to_string(); + + // Identity should be the same + assert_eq!(pubkey1, pubkey2, "public key should persist"); + assert_eq!(name1, name2, "name should persist"); + } } diff --git a/conversations/src/errors.rs b/conversations/src/errors.rs index d551960..f47004c 100644 --- a/conversations/src/errors.rs +++ b/conversations/src/errors.rs @@ -1,5 +1,7 @@ pub use thiserror::Error; +use storage::StorageError; + #[derive(Error, Debug)] pub enum ChatError { #[error("protocol error: {0:?}")] @@ -20,6 +22,8 @@ pub enum ChatError { BadParsing(&'static str), #[error("convo with id: {0} was not found")] NoConvo(String), + #[error("storage error: {0}")] + Storage(#[from] StorageError), } #[derive(Error, Debug)] diff --git a/conversations/src/ffi/mod.rs b/conversations/src/ffi/mod.rs new file mode 100644 index 0000000..b5614dd --- /dev/null +++ b/conversations/src/ffi/mod.rs @@ -0,0 +1 @@ +pub mod utils; diff --git a/conversations/src/ffi/utils.rs b/conversations/src/ffi/utils.rs new file mode 100644 index 0000000..7989631 --- /dev/null +++ b/conversations/src/ffi/utils.rs @@ -0,0 +1,8 @@ +use safer_ffi::prelude::*; + +#[derive_ReprC] +#[repr(C)] +pub struct CResult { + pub ok: Option, + pub err: Option, +} diff --git a/conversations/src/identity.rs b/conversations/src/identity.rs index 76c2700..8ca27be 100644 --- a/conversations/src/identity.rs +++ b/conversations/src/identity.rs @@ -24,6 +24,13 @@ impl Identity { } } + pub fn from_secret(name: impl Into, secret: PrivateKey) -> Self { + Self { + name: name.into(), + secret, + } + } + pub fn public_key(&self) -> PublicKey { PublicKey::from(&self.secret) } diff --git a/conversations/src/lib.rs b/conversations/src/lib.rs index 79d6a5a..5490629 100644 --- a/conversations/src/lib.rs +++ b/conversations/src/lib.rs @@ -3,9 +3,11 @@ mod context; mod conversation; mod crypto; mod errors; +mod ffi; mod identity; mod inbox; mod proto; +mod storage; mod types; mod utils; diff --git a/conversations/src/storage/db.rs b/conversations/src/storage/db.rs new file mode 100644 index 0000000..c855416 --- /dev/null +++ b/conversations/src/storage/db.rs @@ -0,0 +1,111 @@ +//! Chat-specific storage implementation. + +use storage::{RusqliteError, SqliteDb, StorageConfig, StorageError, params}; +use zeroize::Zeroize; + +use super::migrations; +use super::types::IdentityRecord; +use crate::identity::Identity; + +/// Chat-specific storage operations. +/// +/// This struct wraps a SqliteDb and provides domain-specific +/// storage operations for chat state (identity, inbox keys, chat metadata). +/// +/// Note: Ratchet state persistence is delegated to double_ratchets::RatchetStorage. +pub struct ChatStorage { + db: SqliteDb, +} + +impl ChatStorage { + /// Creates a new ChatStorage with the given configuration. + pub fn new(config: StorageConfig) -> Result { + let db = SqliteDb::new(config)?; + Self::run_migrations(db) + } + + /// Applies all migrations and returns the storage instance. + fn run_migrations(mut db: SqliteDb) -> Result { + migrations::apply_migrations(db.connection_mut())?; + Ok(Self { db }) + } + + // ==================== Identity Operations ==================== + + /// Saves the identity (secret key). + /// + /// Note: The secret key bytes are explicitly zeroized after use to minimize + /// the time sensitive data remains in stack memory. + pub fn save_identity(&mut self, identity: &Identity) -> Result<(), StorageError> { + let mut secret_bytes = identity.secret().DANGER_to_bytes(); + let result = self.db.connection().execute( + "INSERT OR REPLACE INTO identity (id, name, secret_key) VALUES (1, ?1, ?2)", + params![identity.get_name(), secret_bytes.as_slice()], + ); + secret_bytes.zeroize(); + result?; + Ok(()) + } + + /// Loads the identity if it exists. + /// + /// Note: Secret key bytes are zeroized after being copied into IdentityRecord, + /// which handles its own zeroization via ZeroizeOnDrop. + pub fn load_identity(&self) -> Result, StorageError> { + let mut stmt = self + .db + .connection() + .prepare("SELECT name, secret_key FROM identity WHERE id = 1")?; + + let result = stmt.query_row([], |row| { + let name: String = row.get(0)?; + let secret_key: Vec = row.get(1)?; + Ok((name, secret_key)) + }); + + match result { + Ok((name, mut secret_key_vec)) => { + let bytes: Result<[u8; 32], _> = secret_key_vec.as_slice().try_into(); + let bytes = match bytes { + Ok(b) => b, + Err(_) => { + secret_key_vec.zeroize(); + return Err(StorageError::InvalidData( + "Invalid secret key length".into(), + )); + } + }; + secret_key_vec.zeroize(); + let record = IdentityRecord { + name, + secret_key: bytes, + }; + Ok(Some(Identity::from(record))) + } + Err(RusqliteError::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(e.into()), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_identity_roundtrip() { + let mut storage = ChatStorage::new(StorageConfig::InMemory).unwrap(); + + // Initially no identity + assert!(storage.load_identity().unwrap().is_none()); + + // Save identity + let identity = Identity::new("default"); + let pubkey = identity.public_key(); + storage.save_identity(&identity).unwrap(); + + // Load identity + let loaded = storage.load_identity().unwrap().unwrap(); + assert_eq!(loaded.public_key(), pubkey); + } +} diff --git a/conversations/src/storage/migrations.rs b/conversations/src/storage/migrations.rs new file mode 100644 index 0000000..014bb96 --- /dev/null +++ b/conversations/src/storage/migrations.rs @@ -0,0 +1,46 @@ +//! Database migrations module. +//! +//! SQL migrations are embedded at compile time and applied in order. +//! Each migration is applied atomically within a transaction. + +use storage::{Connection, StorageError}; + +/// Embeds and returns all migration SQL files in order. +pub fn get_migrations() -> Vec<(&'static str, &'static str)> { + vec![( + "001_initial_schema", + include_str!("migrations/001_initial_schema.sql"), + )] +} + +/// Applies all migrations to the database. +/// +/// Uses a simple version tracking table to avoid re-running migrations. +pub fn apply_migrations(conn: &mut Connection) -> Result<(), StorageError> { + // Create migrations tracking table if it doesn't exist + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS _migrations ( + name TEXT PRIMARY KEY, + applied_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) + );", + )?; + + for (name, sql) in get_migrations() { + // Check if migration already applied + let already_applied: bool = conn.query_row( + "SELECT EXISTS(SELECT 1 FROM _migrations WHERE name = ?1)", + [name], + |row| row.get(0), + )?; + + if !already_applied { + // Apply migration and record it atomically in a transaction + let tx = conn.transaction()?; + tx.execute_batch(sql)?; + tx.execute("INSERT INTO _migrations (name) VALUES (?1)", [name])?; + tx.commit()?; + } + } + + Ok(()) +} diff --git a/conversations/src/storage/migrations/001_initial_schema.sql b/conversations/src/storage/migrations/001_initial_schema.sql new file mode 100644 index 0000000..5a97bfe --- /dev/null +++ b/conversations/src/storage/migrations/001_initial_schema.sql @@ -0,0 +1,9 @@ +-- Initial schema for chat storage +-- Migration: 001_initial_schema + +-- Identity table (single row) +CREATE TABLE IF NOT EXISTS identity ( + id INTEGER PRIMARY KEY CHECK (id = 1), + name TEXT NOT NULL, + secret_key BLOB NOT NULL +); diff --git a/conversations/src/storage/mod.rs b/conversations/src/storage/mod.rs new file mode 100644 index 0000000..9364aeb --- /dev/null +++ b/conversations/src/storage/mod.rs @@ -0,0 +1,7 @@ +//! Storage module for persisting chat state. + +mod db; +mod migrations; +pub(crate) mod types; + +pub(crate) use db::ChatStorage; diff --git a/conversations/src/storage/types.rs b/conversations/src/storage/types.rs new file mode 100644 index 0000000..8767324 --- /dev/null +++ b/conversations/src/storage/types.rs @@ -0,0 +1,58 @@ +//! Storage record types for serialization/deserialization. + +use zeroize::{Zeroize, ZeroizeOnDrop}; + +use crate::crypto::PrivateKey; +use crate::identity::Identity; + +/// Record for storing identity (secret key). +/// Implements ZeroizeOnDrop to securely clear secret key from memory. +#[derive(Debug, Zeroize, ZeroizeOnDrop)] +pub struct IdentityRecord { + /// The identity name. + pub name: String, + /// The secret key bytes (32 bytes). + pub secret_key: [u8; 32], +} + +impl From for Identity { + fn from(record: IdentityRecord) -> Self { + let name = record.name.clone(); + let secret = PrivateKey::from(record.secret_key); + Identity::from_secret(name, secret) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_identity_record_zeroize() { + let secret_key = [0xAB_u8; 32]; + let mut record = IdentityRecord { + name: "test".to_string(), + secret_key, + }; + + // Get a pointer to the secret key before zeroizing + let ptr = record.secret_key.as_ptr(); + + // Manually zeroize (simulates what ZeroizeOnDrop does) + record.zeroize(); + + // Verify the memory is zeroed + // SAFETY: ptr still points to valid memory within record + unsafe { + let slice = std::slice::from_raw_parts(ptr, 32); + assert!(slice.iter().all(|&b| b == 0), "secret_key should be zeroed"); + } + + // Also verify via the struct field + assert!( + record.secret_key.iter().all(|&b| b == 0), + "secret_key field should be zeroed" + ); + assert!(record.name.is_empty(), "name should be cleared"); + } +} diff --git a/storage/src/errors.rs b/storage/src/errors.rs index 1410f85..9d65d64 100644 --- a/storage/src/errors.rs +++ b/storage/src/errors.rs @@ -26,6 +26,10 @@ pub enum StorageError { /// Transaction error. #[error("transaction error: {0}")] Transaction(String), + + /// Invalid data error. + #[error("invalid data: {0}")] + InvalidData(String), } impl From for StorageError { diff --git a/storage/src/lib.rs b/storage/src/lib.rs index bacc9b6..9240dc2 100644 --- a/storage/src/lib.rs +++ b/storage/src/lib.rs @@ -12,4 +12,4 @@ pub use errors::StorageError; pub use sqlite::{SqliteDb, StorageConfig}; // Re-export rusqlite types that domain crates will need -pub use rusqlite::{Error as RusqliteError, Transaction, params}; +pub use rusqlite::{Connection, Error as RusqliteError, Transaction, params}; diff --git a/storage/src/sqlite.rs b/storage/src/sqlite.rs index 4d42e9d..c6b00ab 100644 --- a/storage/src/sqlite.rs +++ b/storage/src/sqlite.rs @@ -66,6 +66,13 @@ impl SqliteDb { &self.conn } + /// Returns a mutable reference to the underlying connection. + /// + /// Use this for operations that require mutable access, such as transactions. + pub fn connection_mut(&mut self) -> &mut Connection { + &mut self.conn + } + /// Begins a transaction. pub fn transaction(&mut self) -> Result, StorageError> { Ok(self.conn.transaction()?)