From f3aa5d5cf01d6dcc8cba46b5cdb3c9ba9ffeab9f Mon Sep 17 00:00:00 2001 From: kaichaosun Date: Fri, 27 Feb 2026 12:53:13 +0800 Subject: [PATCH 01/12] feat: storage for conversations --- Cargo.lock | 2 + conversations/Cargo.toml | 4 + conversations/src/chat.rs | 615 ++++++++++++++++++++ conversations/src/conversation/privatev1.rs | 48 +- conversations/src/errors.rs | 2 + conversations/src/lib.rs | 2 + conversations/src/storage/db.rs | 260 +++++++++ conversations/src/storage/mod.rs | 14 + conversations/src/storage/types.rs | 62 ++ double-ratchets/src/storage/db.rs | 6 + double-ratchets/src/storage/session.rs | 202 +++---- storage/src/sqlite.rs | 16 +- 12 files changed, 1114 insertions(+), 119 deletions(-) create mode 100644 conversations/src/chat.rs create mode 100644 conversations/src/storage/db.rs create mode 100644 conversations/src/storage/mod.rs create mode 100644 conversations/src/storage/types.rs diff --git a/Cargo.lock b/Cargo.lock index bcdb99d..bffaee7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -501,6 +501,8 @@ dependencies = [ "prost", "rand_core", "safer-ffi", + "storage", + "tempfile", "thiserror", "x25519-dalek", ] diff --git a/conversations/Cargo.toml b/conversations/Cargo.toml index cdb02a9..b77d9f6 100644 --- a/conversations/Cargo.toml +++ b/conversations/Cargo.toml @@ -18,3 +18,7 @@ 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" } + +[dev-dependencies] +tempfile = "3" diff --git a/conversations/src/chat.rs b/conversations/src/chat.rs new file mode 100644 index 0000000..19b9be1 --- /dev/null +++ b/conversations/src/chat.rs @@ -0,0 +1,615 @@ +//! ChatManager with integrated SQLite persistence. +//! +//! This is the main entry point for the conversations API. It handles all +//! storage operations internally - users don't need to interact with storage directly. + +use std::rc::Rc; + +use double_ratchets::storage::RatchetStorage; +use prost::Message; + +use crate::{ + conversation::PrivateV1Convo, + conversation::{Convo, Id}, + errors::ChatError, + identity::Identity, + inbox::{Inbox, Introduction}, + proto, + storage::{ChatRecord, ChatStorage, StorageError}, + types::{AddressedEnvelope, ContentData}, +}; + +// Re-export StorageConfig from storage crate for convenience +pub use storage::StorageConfig; + +/// Error type for ChatManager operations. +#[derive(Debug, thiserror::Error)] +pub enum ChatManagerError { + #[error("chat error: {0}")] + Chat(#[from] ChatError), + + #[error("storage error: {0}")] + Storage(#[from] StorageError), + + #[error("chat not found: {0}")] + ChatNotFound(String), +} + +/// ChatManager is the main entry point for the chat API. +/// +/// It manages identity, inbox, and chats with all state persisted to SQLite. +/// Chats are loaded from storage on each operation - no in-memory caching. +/// Uses a single shared database for both chat metadata and ratchet state. +/// +/// # Example +/// +/// ```ignore +/// // Create a new chat manager with encrypted storage +/// let mut chat = ChatManager::open(StorageConfig::Encrypted { +/// path: "chat.db".into(), +/// key: "my_secret_key".into(), +/// })?; +/// +/// // Get your address to share with others +/// println!("My address: {}", chat.local_address()); +/// +/// // Create an intro bundle to share +/// let intro = chat.create_intro_bundle()?; +/// +/// // Start a chat with someone +/// let (chat_id, envelopes) = chat.start_private_chat(&their_intro, "Hello!")?; +/// // Send envelopes over the network... +/// +/// // Send more messages +/// let envelopes = chat.send_message(&chat_id, b"How are you?")?; +/// ``` +pub struct ChatManager { + identity: Rc, + inbox: Inbox, + /// Storage for chat metadata (identity, inbox keys, chat records). + storage: ChatStorage, + /// Storage config for creating ratchet storage instances. + /// For file/encrypted databases, SQLite handles connection efficiently. + /// For in-memory testing, use SharedInMemory to share data. + storage_config: StorageConfig, +} + +impl ChatManager { + /// Opens or creates a ChatManager with the given storage configuration. + /// + /// If an identity exists in storage, it will be restored. + /// Otherwise, a new identity will be created and saved. + pub fn open(config: StorageConfig) -> Result { + let mut storage = ChatStorage::new(config.clone())?; + + // Load or create identity + let identity = if let Some(identity) = storage.load_identity()? { + identity + } else { + let identity = Identity::new("default"); + storage.save_identity(&identity)?; + identity + }; + + let identity = Rc::new(identity); + let inbox = Inbox::new(Rc::clone(&identity)); + + Ok(Self { + identity, + inbox, + storage, + storage_config: config, + }) + } + + /// Creates a new in-memory ChatManager (for testing). + /// + /// Uses a shared in-memory SQLite database so that multiple storage + /// instances within the same ChatManager share data. + /// + /// The `db_name` should be unique per ChatManager instance to avoid + /// sharing data between different users. + pub fn in_memory(db_name: &str) -> Result { + Self::open(StorageConfig::SharedInMemory(db_name.to_string())) + } + + /// Creates a new RatchetStorage instance using the stored config. + fn create_ratchet_storage(&self) -> Result { + Ok(RatchetStorage::with_config(self.storage_config.clone())?) + } + + /// Load a chat from storage. + fn load_chat(&self, chat_id: &str) -> Result { + let ratchet_storage = self.create_ratchet_storage()?; + if ratchet_storage.exists(chat_id)? { + let base_conv_id = chat_id.parse()?; + Ok(PrivateV1Convo::open(ratchet_storage, base_conv_id)?) + } else if self.storage.chat_exists(chat_id)? { + // Chat metadata exists but no ratchet state - data inconsistency + Err(ChatManagerError::ChatNotFound(format!( + "{} (corrupted: missing ratchet state)", + chat_id + ))) + } else { + Err(ChatManagerError::ChatNotFound(chat_id.to_string())) + } + } + + /// Get the local identity's public address. + /// + /// This address can be shared with others so they can identify you. + pub fn local_address(&self) -> String { + hex::encode(self.identity.public_key().as_bytes()) + } + + /// Create an introduction bundle that can be shared with others. + /// + /// Others can use this bundle to initiate a chat with you. + /// Share it via QR code, link, or any other out-of-band method. + /// + /// The ephemeral key is automatically persisted to storage. + pub fn create_intro_bundle(&mut self) -> Result { + let (pkb, secret) = self.inbox.create_bundle(); + let intro = Introduction::from(pkb); + + // Persist the ephemeral key + let public_key_hex = hex::encode(intro.ephemeral_key.as_bytes()); + self.storage.save_inbox_key(&public_key_hex, &secret)?; + + Ok(intro) + } + + /// Start a new private conversation with someone using their introduction bundle. + /// + /// Returns the chat ID and envelopes that must be delivered to the remote party. + /// The chat state is automatically persisted (via RatchetSession). + pub fn start_private_chat( + &mut self, + remote_bundle: &Introduction, + initial_message: &str, + ) -> Result<(String, Vec), ChatManagerError> { + // Create new storage for this conversation's RatchetSession + let ratchet_storage = self.create_ratchet_storage()?; + + let (convo, payloads) = self.inbox.invite_to_private_convo( + ratchet_storage, + remote_bundle, + initial_message.to_string(), + )?; + + let chat_id = convo.id().to_string(); + + let envelopes: Vec = payloads + .into_iter() + .map(|p| p.to_envelope(chat_id.clone())) + .collect(); + + // Persist chat metadata + let chat_record = ChatRecord::new_private( + chat_id.clone(), + remote_bundle.installation_key, + payloads_delivery_address(&envelopes), + ); + self.storage.save_chat(&chat_record)?; + + // Ratchet state is automatically persisted by RatchetSession + // convo is dropped here - state already saved + + Ok((chat_id, envelopes)) + } + + /// Send a message to an existing chat. + /// + /// Returns envelopes that must be delivered to chat participants. + pub fn send_message( + &mut self, + chat_id: &str, + content: &[u8], + ) -> Result, ChatManagerError> { + // Load chat from storage + let mut chat = self.load_chat(chat_id)?; + + let payloads = chat.send_message(content)?; + + // Ratchet state is automatically persisted by RatchetSession + + let remote_id = chat.remote_id(); + Ok(payloads + .into_iter() + .map(|p| p.to_envelope(remote_id.clone())) + .collect()) + } + + /// Handle an incoming payload from the network. + /// + /// This processes both inbox handshakes (to establish new chats) and + /// messages for existing chats. + /// + /// Returns the decrypted content if successful. + /// Any new chats or state changes are automatically persisted. + pub fn handle_incoming(&mut self, payload: &[u8]) -> Result { + // Try to decode as an envelope + if let Ok(envelope) = proto::EnvelopeV1::decode(payload) { + let chat_id = &envelope.conversation_hint; + + // Check if we have this chat - if so, route to it for decryption + if !chat_id.is_empty() && self.chat_exists(chat_id)? { + return self.receive_message(chat_id, &envelope.payload); + } + + // We don't have this chat - try to handle as inbox handshake + // Pass the conversation_hint so both parties use the same chat ID + return self.handle_inbox_handshake(chat_id, &envelope.payload); + } + + // Not a valid envelope format + Err(ChatManagerError::Chat(ChatError::Protocol( + "invalid envelope format".to_string(), + ))) + } + + /// Handle an inbox handshake to establish a new chat. + fn handle_inbox_handshake( + &mut self, + conversation_hint: &str, + payload: &[u8], + ) -> Result { + // Extract the ephemeral key hex from the payload + let key_hex = Inbox::extract_ephemeral_key_hex(payload)?; + + // Load the ephemeral key from storage + let ephemeral_key = self + .storage + .load_inbox_key(&key_hex)? + .ok_or_else(|| ChatManagerError::Chat(ChatError::UnknownEphemeralKey()))?; + + let ratchet_storage = self.create_ratchet_storage()?; + let result = + self.inbox + .handle_frame(ratchet_storage, conversation_hint, payload, &ephemeral_key)?; + + let chat_id = result.convo.id().to_string(); + + // Persist the new chat metadata + let chat_record = ChatRecord { + chat_id: chat_id.clone(), + chat_type: "private_v1".to_string(), + remote_public_key: Some(result.remote_public_key), + remote_address: hex::encode(result.remote_public_key), + created_at: crate::utils::timestamp_millis() as i64, + }; + self.storage.save_chat(&chat_record)?; + + // Delete the ephemeral key from storage after successful handshake + self.storage.delete_inbox_key(&key_hex)?; + + // Ratchet state is automatically persisted by RatchetSession + // result.convo is dropped here - state already saved + + Ok(ContentData { + conversation_id: chat_id, + data: result.initial_content.unwrap_or_default(), + }) + } + + /// Receive and decrypt a message for an existing chat. + /// + /// The payload should be the raw encrypted payload bytes. + pub fn receive_message( + &mut self, + chat_id: &str, + payload: &[u8], + ) -> Result { + // Load chat from storage + let mut chat = self.load_chat(chat_id)?; + + // Decode and decrypt the payload + let encrypted_payload = proto::EncryptedPayload::decode(payload).map_err(|e| { + ChatManagerError::Chat(ChatError::Protocol(format!("failed to decode: {}", e))) + })?; + + let frame = chat.decrypt(encrypted_payload)?; + let content = PrivateV1Convo::extract_content(&frame).unwrap_or_default(); + + // Ratchet state is automatically persisted by RatchetSession + + Ok(ContentData { + conversation_id: chat_id.to_string(), + data: content, + }) + } + + /// List all chat IDs from storage. + pub fn list_chats(&self) -> Result, ChatManagerError> { + Ok(self.storage.list_chat_ids()?) + } + + /// Check if a chat exists in storage. + pub fn chat_exists(&self, chat_id: &str) -> Result { + Ok(self.storage.chat_exists(chat_id)?) + } + + /// Delete a chat from storage. + pub fn delete_chat(&mut self, chat_id: &str) -> Result<(), ChatManagerError> { + self.storage.delete_chat(chat_id)?; + // Also delete ratchet state from double-ratchets storage + if let Ok(mut ratchet_storage) = self.create_ratchet_storage() { + let _ = ratchet_storage.delete(chat_id); + } + Ok(()) + } +} + +/// Extract delivery address from envelopes (helper function). +fn payloads_delivery_address(envelopes: &[AddressedEnvelope]) -> String { + envelopes + .first() + .map(|e| e.delivery_address.clone()) + .unwrap_or_else(|| "unknown".to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_create_chat_manager() { + let manager = ChatManager::in_memory("test1").unwrap(); + assert!(!manager.local_address().is_empty()); + } + + #[test] + fn test_identity_persistence() { + let manager = ChatManager::in_memory("test2").unwrap(); + let address = manager.local_address(); + + // Identity should be persisted + let loaded = manager.storage.load_identity().unwrap(); + assert!(loaded.is_some()); + assert_eq!(loaded.unwrap().address(), address); + } + + #[test] + fn test_create_intro_bundle() { + let mut manager = ChatManager::in_memory("test3").unwrap(); + let bundle = manager.create_intro_bundle(); + assert!(bundle.is_ok()); + } + + #[test] + fn test_start_private_chat() { + let mut alice = ChatManager::in_memory("alice1").unwrap(); + let mut bob = ChatManager::in_memory("bob1").unwrap(); + + // Bob creates an intro bundle + let bob_intro = bob.create_intro_bundle().unwrap(); + + // Alice starts a chat with Bob + let result = alice.start_private_chat(&bob_intro, "Hello Bob!"); + assert!(result.is_ok()); + + let (chat_id, envelopes) = result.unwrap(); + assert!(!chat_id.is_empty()); + assert!(!envelopes.is_empty()); + + // Chat should be persisted + let stored = alice.list_chats().unwrap(); + assert!(stored.contains(&chat_id)); + } + + #[test] + fn test_inbox_key_persistence() { + let mut manager = ChatManager::in_memory("test4").unwrap(); + + // Create intro bundle (should persist ephemeral key) + let intro = manager.create_intro_bundle().unwrap(); + let key_hex = hex::encode(intro.ephemeral_key.as_bytes()); + + // Key should be persisted - load it directly + let loaded_key = manager.storage.load_inbox_key(&key_hex).unwrap(); + assert!(loaded_key.is_some()); + } + + #[test] + fn test_chat_exists() { + let mut alice = ChatManager::in_memory("alice2").unwrap(); + let mut bob = ChatManager::in_memory("bob2").unwrap(); + + let bob_intro = bob.create_intro_bundle().unwrap(); + let (chat_id, _) = alice.start_private_chat(&bob_intro, "Hello!").unwrap(); + + // Chat should exist + assert!(alice.chat_exists(&chat_id).unwrap()); + assert!(!alice.chat_exists("nonexistent").unwrap()); + } + + #[test] + fn test_delete_chat() { + let mut alice = ChatManager::in_memory("alice3").unwrap(); + let mut bob = ChatManager::in_memory("bob3").unwrap(); + + let bob_intro = bob.create_intro_bundle().unwrap(); + let (chat_id, _) = alice.start_private_chat(&bob_intro, "Hello!").unwrap(); + + // Delete chat + alice.delete_chat(&chat_id).unwrap(); + + // Chat should no longer exist + assert!(!alice.chat_exists(&chat_id).unwrap()); + assert!(alice.list_chats().unwrap().is_empty()); + } + + #[test] + fn test_ratchet_state_persistence() { + use tempfile::tempdir; + + // Create a temporary directory for the database + let dir = tempdir().unwrap(); + let db_path = dir.path().join("test.db"); + + let mut bob = ChatManager::in_memory("bob4").unwrap(); + let bob_intro = bob.create_intro_bundle().unwrap(); + + let chat_id; + + // Scope 1: Create chat and send messages + { + let mut alice = + ChatManager::open(StorageConfig::File(db_path.to_str().unwrap().to_string())) + .unwrap(); + + let result = alice.start_private_chat(&bob_intro, "Message 1").unwrap(); + chat_id = result.0; + + // Send more messages - this advances the ratchet + alice.send_message(&chat_id, b"Message 2").unwrap(); + alice.send_message(&chat_id, b"Message 3").unwrap(); + + // Chat should be in storage + assert!(alice.chat_exists(&chat_id).unwrap()); + } + // alice is dropped here, simulating app close + + // Scope 2: Reopen and verify chat is restored + { + let mut alice2 = + ChatManager::open(StorageConfig::File(db_path.to_str().unwrap().to_string())) + .unwrap(); + + // Chat should still be in storage + assert!(alice2.list_chats().unwrap().contains(&chat_id)); + + // Send another message - this will load the chat and advance ratchet + let result = alice2.send_message(&chat_id, b"Message 4"); + assert!(result.is_ok(), "Should be able to send after restore"); + } + } + + #[test] + fn test_full_message_roundtrip() { + use tempfile::tempdir; + + // Use temp files instead of in-memory for proper storage sharing + let dir = tempdir().unwrap(); + let alice_db = dir.path().join("alice.db"); + let bob_db = dir.path().join("bob.db"); + + let mut alice = + ChatManager::open(StorageConfig::File(alice_db.to_str().unwrap().to_string())).unwrap(); + let mut bob = + ChatManager::open(StorageConfig::File(bob_db.to_str().unwrap().to_string())).unwrap(); + + // Bob creates an intro bundle and shares it with Alice + let bob_intro = bob.create_intro_bundle().unwrap(); + + // Alice starts a chat with Bob and sends "Hello!" + let (alice_chat_id, envelopes) = + alice.start_private_chat(&bob_intro, "Hello Bob!").unwrap(); + + // Verify Alice has the chat + assert!(alice.chat_exists(&alice_chat_id).unwrap()); + assert_eq!(alice.list_chats().unwrap().len(), 1); + + // Simulate network delivery: Bob receives the envelope + let envelope = envelopes.first().unwrap(); + let content = bob.handle_incoming(&envelope.data).unwrap(); + + // Bob should have received the message + assert_eq!(content.data, b"Hello Bob!"); + + // Bob should now have a chat + assert_eq!(bob.list_chats().unwrap().len(), 1); + let bob_chat_id = bob.list_chats().unwrap().first().unwrap().clone(); + + // Bob replies to Alice + let bob_reply_envelopes = bob.send_message(&bob_chat_id, b"Hi Alice!").unwrap(); + assert!(!bob_reply_envelopes.is_empty()); + + // Alice receives Bob's reply + let bob_reply = bob_reply_envelopes.first().unwrap(); + let alice_received = alice.handle_incoming(&bob_reply.data).unwrap(); + + assert_eq!(alice_received.data, b"Hi Alice!"); + assert_eq!(alice_received.conversation_id, alice_chat_id); + + // Continue the conversation - Alice sends another message + let alice_envelopes = alice.send_message(&alice_chat_id, b"How are you?").unwrap(); + let alice_msg = alice_envelopes.first().unwrap(); + let bob_received = bob.handle_incoming(&alice_msg.data).unwrap(); + + assert_eq!(bob_received.data, b"How are you?"); + + // Bob replies again + let bob_envelopes = bob + .send_message(&bob_chat_id, b"I'm good, thanks!") + .unwrap(); + let bob_msg = bob_envelopes.first().unwrap(); + let alice_received2 = alice.handle_incoming(&bob_msg.data).unwrap(); + + assert_eq!(alice_received2.data, b"I'm good, thanks!"); + } + + #[test] + fn test_message_persistence_across_sessions() { + use tempfile::tempdir; + + let dir = tempdir().unwrap(); + let alice_db = dir.path().join("alice.db"); + let bob_db = dir.path().join("bob.db"); + + let alice_chat_id; + let bob_chat_id; + let bob_intro; + + // Phase 1: Establish chat + { + let mut alice = + ChatManager::open(StorageConfig::File(alice_db.to_str().unwrap().to_string())) + .unwrap(); + let mut bob = + ChatManager::open(StorageConfig::File(bob_db.to_str().unwrap().to_string())) + .unwrap(); + + bob_intro = bob.create_intro_bundle().unwrap(); + let (chat_id, envelopes) = alice.start_private_chat(&bob_intro, "Initial").unwrap(); + alice_chat_id = chat_id; + + // Bob receives + let envelope = envelopes.first().unwrap(); + let content = bob.handle_incoming(&envelope.data).unwrap(); + assert_eq!(content.data, b"Initial"); + bob_chat_id = bob.list_chats().unwrap().first().unwrap().clone(); + } + // Both dropped - simulates app restart + + // Phase 2: Continue conversation after restart + { + let mut alice = + ChatManager::open(StorageConfig::File(alice_db.to_str().unwrap().to_string())) + .unwrap(); + let mut bob = + ChatManager::open(StorageConfig::File(bob_db.to_str().unwrap().to_string())) + .unwrap(); + + // Both should have persisted chats + assert!(alice.list_chats().unwrap().contains(&alice_chat_id)); + assert!(bob.list_chats().unwrap().contains(&bob_chat_id)); + + // Alice sends a message (chat loads from storage) + let envelopes = alice + .send_message(&alice_chat_id, b"After restart") + .unwrap(); + + // Bob receives (chat loads from storage) + let envelope = envelopes.first().unwrap(); + let content = bob.handle_incoming(&envelope.data).unwrap(); + assert_eq!(content.data, b"After restart"); + + // Bob replies + let bob_envelopes = bob.send_message(&bob_chat_id, b"Still works!").unwrap(); + let bob_msg = bob_envelopes.first().unwrap(); + let alice_received = alice.handle_incoming(&bob_msg.data).unwrap(); + assert_eq!(alice_received.data, b"Still works!"); + } + } +} diff --git a/conversations/src/conversation/privatev1.rs b/conversations/src/conversation/privatev1.rs index 0b8042e..b924d3e 100644 --- a/conversations/src/conversation/privatev1.rs +++ b/conversations/src/conversation/privatev1.rs @@ -7,9 +7,12 @@ use chat_proto::logoschat::{ encryption::{Doubleratchet, EncryptedPayload, encrypted_payload::Encryption}, }; use crypto::{PrivateKey, PublicKey, SymmetricKey32}; -use double_ratchets::{Header, InstallationKeyPair, RatchetState}; +use double_ratchets::{Header, InstallationKeyPair, RatchetSession, RatchetState, RatchetStorage}; use prost::{Message, bytes::Bytes}; -use std::fmt::Debug; +use std::{ + fmt::{self, Debug, Display, Formatter}, + str::FromStr, +}; use crate::{ conversation::{ChatError, ConversationId, Convo, Id}, @@ -52,10 +55,34 @@ impl BaseConvoId { } } +impl Display for BaseConvoId { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "{}", hex::encode(self.0)) + } +} + +impl FromStr for BaseConvoId { + type Err = ChatError; + + fn from_str(s: &str) -> Result { + let bytes = hex::decode(s).map_err(|_| ChatError::BadParsing("base conversation ID"))?; + + if bytes.len() != 18 { + return Err(ChatError::BadParsing("base conversation ID")); + } + + let mut arr = [0u8; 18]; + arr.copy_from_slice(&bytes); + + Ok(Self(arr)) + } +} + pub struct PrivateV1Convo { local_convo_id: String, remote_convo_id: String, dr_state: RatchetState, + session: Option, } impl PrivateV1Convo { @@ -74,6 +101,7 @@ impl PrivateV1Convo { local_convo_id, remote_convo_id, dr_state, + session: None, } } @@ -93,9 +121,25 @@ impl PrivateV1Convo { local_convo_id, remote_convo_id, dr_state, + session: None, } } + /// Open an existing conversation from storage. + pub fn open(storage: RatchetStorage, base_convo_id: BaseConvoId) -> Result { + let local_convo_id = base_convo_id.id_for_participant(Role::Responder); + let remote_convo_id = base_convo_id.id_for_participant(Role::Initiator); + + let session = RatchetSession::open(storage, &local_convo_id)?; + + Ok(Self { + local_convo_id, + remote_convo_id, + dr_state: session.state().clone(), + session: Some(session), + }) + } + fn encrypt(&mut self, frame: PrivateV1Frame) -> EncryptedPayload { let encoded_bytes = frame.encode_to_vec(); let (cipher_text, header) = self.dr_state.encrypt_message(&encoded_bytes); diff --git a/conversations/src/errors.rs b/conversations/src/errors.rs index d551960..1df0e68 100644 --- a/conversations/src/errors.rs +++ b/conversations/src/errors.rs @@ -20,6 +20,8 @@ pub enum ChatError { BadParsing(&'static str), #[error("convo with id: {0} was not found")] NoConvo(String), + #[error("session error: {0}")] + Session(#[from] double_ratchets::SessionError), } #[derive(Error, Debug)] diff --git a/conversations/src/lib.rs b/conversations/src/lib.rs index 79d6a5a..b82bb22 100644 --- a/conversations/src/lib.rs +++ b/conversations/src/lib.rs @@ -1,4 +1,5 @@ mod api; +mod chat; mod context; mod conversation; mod crypto; @@ -6,6 +7,7 @@ mod errors; 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..3bd0085 --- /dev/null +++ b/conversations/src/storage/db.rs @@ -0,0 +1,260 @@ +//! Chat-specific storage implementation. + +use storage::{RusqliteError, SqliteDb, StorageConfig, StorageError, params}; +use x25519_dalek::StaticSecret; + +use super::types::{ChatRecord, IdentityRecord}; +use crate::identity::Identity; + +/// Schema for chat storage tables. +/// Note: Ratchet state is stored by double_ratchets::RatchetStorage separately. +const CHAT_SCHEMA: &str = " + -- Identity table (single row) + CREATE TABLE IF NOT EXISTS identity ( + id INTEGER PRIMARY KEY CHECK (id = 1), + secret_key BLOB NOT NULL + ); + + -- Inbox ephemeral keys for handshakes + CREATE TABLE IF NOT EXISTS inbox_keys ( + public_key_hex TEXT PRIMARY KEY, + secret_key BLOB NOT NULL, + created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) + ); + + -- Chat metadata + CREATE TABLE IF NOT EXISTS chats ( + chat_id TEXT PRIMARY KEY, + chat_type TEXT NOT NULL, + remote_public_key BLOB, + remote_address TEXT NOT NULL, + created_at INTEGER NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_chats_type ON chats(chat_type); +"; + +/// 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_migration(db) + } + + /// Creates a new chat storage with the given database. + fn run_migration(db: SqliteDb) -> Result { + db.connection().execute_batch(CHAT_SCHEMA)?; + Ok(Self { db }) + } + + // ==================== Identity Operations ==================== + + /// Saves the identity (secret key). + pub fn save_identity(&mut self, identity: &Identity) -> Result<(), StorageError> { + let record = IdentityRecord::from(identity); + self.db.connection().execute( + "INSERT OR REPLACE INTO identity (id, secret_key) VALUES (1, ?1)", + params![record.secret_key.as_slice()], + )?; + Ok(()) + } + + /// Loads the identity if it exists. + pub fn load_identity(&self) -> Result, StorageError> { + let mut stmt = self + .db + .connection() + .prepare("SELECT secret_key FROM identity WHERE id = 1")?; + + let result = stmt.query_row([], |row| { + let secret_key: Vec = row.get(0)?; + Ok(secret_key) + }); + + match result { + Ok(secret_key) => { + let bytes: [u8; 32] = secret_key + .try_into() + .map_err(|_| StorageError::InvalidData("Invalid secret key length".into()))?; + let record = IdentityRecord { secret_key: bytes }; + Ok(Some(Identity::from(record))) + } + Err(RusqliteError::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(e.into()), + } + } + + // ==================== Inbox Key Operations ==================== + + /// Saves an inbox ephemeral key. + pub fn save_inbox_key( + &mut self, + public_key_hex: &str, + secret: &StaticSecret, + ) -> Result<(), StorageError> { + self.db.connection().execute( + "INSERT OR REPLACE INTO inbox_keys (public_key_hex, secret_key) VALUES (?1, ?2)", + params![public_key_hex, secret.as_bytes().as_slice()], + )?; + Ok(()) + } + + /// Loads a single inbox ephemeral key by public key hex. + pub fn load_inbox_key( + &self, + public_key_hex: &str, + ) -> Result, StorageError> { + let mut stmt = self + .db + .connection() + .prepare("SELECT secret_key FROM inbox_keys WHERE public_key_hex = ?1")?; + + let result = stmt.query_row(params![public_key_hex], |row| { + let secret_key: Vec = row.get(0)?; + Ok(secret_key) + }); + + match result { + Ok(secret_key) => { + let bytes: [u8; 32] = secret_key + .try_into() + .map_err(|_| StorageError::InvalidData("Invalid secret key length".into()))?; + Ok(Some(StaticSecret::from(bytes))) + } + Err(RusqliteError::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(e.into()), + } + } + + /// Deletes an inbox ephemeral key after it has been used. + pub fn delete_inbox_key(&mut self, public_key_hex: &str) -> Result<(), StorageError> { + self.db.connection().execute( + "DELETE FROM inbox_keys WHERE public_key_hex = ?1", + params![public_key_hex], + )?; + Ok(()) + } + + // ==================== Chat Metadata Operations ==================== + + /// Saves a chat record. + pub fn save_chat(&mut self, chat: &ChatRecord) -> Result<(), StorageError> { + self.db.connection().execute( + "INSERT OR REPLACE INTO chats (chat_id, chat_type, remote_public_key, remote_address, created_at) + VALUES (?1, ?2, ?3, ?4, ?5)", + params![ + chat.chat_id, + chat.chat_type, + chat.remote_public_key.as_ref().map(|k| k.as_slice()), + chat.remote_address, + chat.created_at, + ], + )?; + Ok(()) + } + + /// Lists all chat IDs. + pub fn list_chat_ids(&self) -> Result, StorageError> { + let mut stmt = self.db.connection().prepare("SELECT chat_id FROM chats")?; + let rows = stmt.query_map([], |row| row.get(0))?; + + let mut ids = Vec::new(); + for row in rows { + ids.push(row?); + } + + Ok(ids) + } + + /// Checks if a chat exists in storage. + pub fn chat_exists(&self, chat_id: &str) -> Result { + let mut stmt = self + .db + .connection() + .prepare("SELECT 1 FROM chats WHERE chat_id = ?1")?; + + let exists = stmt.exists(params![chat_id])?; + Ok(exists) + } + + /// Finds a chat by remote address. + /// Returns the chat_id if found, None otherwise. + #[allow(dead_code)] + pub fn find_chat_by_remote_address( + &self, + remote_address: &str, + ) -> Result, StorageError> { + let mut stmt = self + .db + .connection() + .prepare("SELECT chat_id FROM chats WHERE remote_address = ?1 LIMIT 1")?; + + let mut rows = stmt.query(params![remote_address])?; + if let Some(row) = rows.next()? { + Ok(Some(row.get(0)?)) + } else { + Ok(None) + } + } + + /// Deletes a chat record. + /// Note: Ratchet state must be deleted separately via RatchetStorage. + pub fn delete_chat(&mut self, chat_id: &str) -> Result<(), StorageError> { + self.db + .connection() + .execute("DELETE FROM chats WHERE chat_id = ?1", params![chat_id])?; + Ok(()) + } +} + +#[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(); + let address = identity.address(); + storage.save_identity(&identity).unwrap(); + + // Load identity + let loaded = storage.load_identity().unwrap().unwrap(); + assert_eq!(loaded.address(), address); + } + + #[test] + fn test_chat_roundtrip() { + let mut storage = ChatStorage::new(StorageConfig::InMemory).unwrap(); + + let secret = x25519_dalek::StaticSecret::random(); + let remote_key = x25519_dalek::PublicKey::from(&secret); + let chat = ChatRecord::new_private( + "chat_123".to_string(), + remote_key, + "delivery_addr".to_string(), + ); + + // Save chat + storage.save_chat(&chat).unwrap(); + + // List chats + let ids = storage.list_chat_ids().unwrap(); + assert_eq!(ids, vec!["chat_123"]); + } +} diff --git a/conversations/src/storage/mod.rs b/conversations/src/storage/mod.rs new file mode 100644 index 0000000..5153dbf --- /dev/null +++ b/conversations/src/storage/mod.rs @@ -0,0 +1,14 @@ +//! Storage module for persisting chat state. +//! +//! This module provides storage implementations for the chat manager state, +//! built on top of the shared `storage` crate. +//! +//! Note: This module is internal. Users should use `ChatManager` which +//! handles all storage operations automatically. + +mod db; +pub(crate) mod types; + +pub(crate) use db::ChatStorage; +pub(crate) use storage::StorageError; +pub(crate) use types::ChatRecord; diff --git a/conversations/src/storage/types.rs b/conversations/src/storage/types.rs new file mode 100644 index 0000000..553ac1b --- /dev/null +++ b/conversations/src/storage/types.rs @@ -0,0 +1,62 @@ +//! Storage record types for serialization/deserialization. +//! +//! Note: Ratchet state types (RatchetStateRecord, SkippedKeyRecord) are in +//! double_ratchets::storage module and handled by RatchetStorage. + +use x25519_dalek::{PublicKey, StaticSecret}; + +use crate::identity::Identity; + +/// Record for storing identity (secret key). +#[derive(Debug)] +pub struct IdentityRecord { + /// The secret key bytes (32 bytes). + pub secret_key: [u8; 32], +} + +impl From<&Identity> for IdentityRecord { + fn from(identity: &Identity) -> Self { + Self { + secret_key: identity.secret().to_bytes(), + } + } +} + +impl From for Identity { + fn from(record: IdentityRecord) -> Self { + let secret = StaticSecret::from(record.secret_key); + Identity::from_secret(secret) + } +} + +/// Record for storing chat metadata. +/// Note: The actual double ratchet state is stored separately by RatchetStorage. +#[derive(Debug, Clone)] +pub struct ChatRecord { + /// Unique chat identifier. + pub chat_id: String, + /// Type of chat (e.g., "private_v1", "group_v1"). + pub chat_type: String, + /// Remote party's public key (for private chats). + pub remote_public_key: Option<[u8; 32]>, + /// Remote party's delivery address. + pub remote_address: String, + /// Creation timestamp (unix millis). + pub created_at: i64, +} + +impl ChatRecord { + pub fn new_private( + chat_id: String, + remote_public_key: PublicKey, + remote_address: String, + ) -> Self { + Self { + chat_id, + chat_type: "private_v1".to_string(), + remote_public_key: Some(remote_public_key.to_bytes()), + remote_address, + created_at: crate::utils::timestamp_millis() as i64, + } + } +} diff --git a/double-ratchets/src/storage/db.rs b/double-ratchets/src/storage/db.rs index 43b3f4b..a41d2bd 100644 --- a/double-ratchets/src/storage/db.rs +++ b/double-ratchets/src/storage/db.rs @@ -47,6 +47,12 @@ pub struct RatchetStorage { } impl RatchetStorage { + /// Creates a new RatchetStorage with the given configuration. + pub fn with_config(config: storage::StorageConfig) -> Result { + let db = SqliteDb::new(config)?; + Self::run_migration(db) + } + /// Opens an existing encrypted database file. pub fn new(path: &str, key: &str) -> Result { let db = SqliteDb::sqlcipher(path.to_string(), key.to_string())?; diff --git a/double-ratchets/src/storage/session.rs b/double-ratchets/src/storage/session.rs index ea3cdfc..2598d85 100644 --- a/double-ratchets/src/storage/session.rs +++ b/double-ratchets/src/storage/session.rs @@ -13,16 +13,19 @@ use super::RatchetStorage; /// A session wrapper that automatically persists ratchet state after operations. /// Provides rollback semantics - state is only saved if the operation succeeds. -pub struct RatchetSession<'a, D: HkdfInfo + Clone = DefaultDomain> { - storage: &'a mut RatchetStorage, +/// +/// This struct owns its storage, making it easy to store in other structs +/// and use across multiple operations without lifetime concerns. +pub struct RatchetSession { + storage: RatchetStorage, conversation_id: String, state: RatchetState, } -impl<'a, D: HkdfInfo + Clone> RatchetSession<'a, D> { +impl<'a, D: HkdfInfo + Clone> RatchetSession { /// Opens an existing session from storage. pub fn open( - storage: &'a mut RatchetStorage, + storage: RatchetStorage, conversation_id: impl Into, ) -> Result { let conversation_id = conversation_id.into(); @@ -36,7 +39,7 @@ impl<'a, D: HkdfInfo + Clone> RatchetSession<'a, D> { /// Creates a new session and persists the initial state. pub fn create( - storage: &'a mut RatchetStorage, + mut storage: RatchetStorage, conversation_id: impl Into, state: RatchetState, ) -> Result { @@ -51,7 +54,7 @@ impl<'a, D: HkdfInfo + Clone> RatchetSession<'a, D> { /// Initializes a new session as a sender and persists the initial state. pub fn create_sender_session( - storage: &'a mut RatchetStorage, + storage: RatchetStorage, conversation_id: &str, shared_secret: SharedSecret, remote_pub: PublicKey, @@ -65,7 +68,7 @@ impl<'a, D: HkdfInfo + Clone> RatchetSession<'a, D> { /// Initializes a new session as a receiver and persists the initial state. pub fn create_receiver_session( - storage: &'a mut RatchetStorage, + storage: RatchetStorage, conversation_id: &str, shared_secret: SharedSecret, dh_self: InstallationKeyPair, @@ -137,6 +140,12 @@ impl<'a, D: HkdfInfo + Clone> RatchetSession<'a, D> { &self.conversation_id } + /// Consumes the session and returns the underlying storage. + /// Useful when you need to reuse the storage for another session. + pub fn into_storage(self) -> RatchetStorage { + self.storage + } + /// Manually saves the current state. pub fn save(&mut self) -> Result<(), SessionError> { self.storage @@ -164,30 +173,29 @@ mod tests { #[test] fn test_session_create_and_open() { - let mut storage = create_test_storage(); + let storage = create_test_storage(); let shared_secret = [0x42; 32]; let bob_keypair = InstallationKeyPair::generate(); let alice: RatchetState = RatchetState::init_sender(shared_secret, *bob_keypair.public()); - // Create session - { - let session = RatchetSession::create(&mut storage, "conv1", alice).unwrap(); - assert_eq!(session.conversation_id(), "conv1"); - } + // Create session - session takes ownership of storage + let session = RatchetSession::create(storage, "conv1", alice).unwrap(); + assert_eq!(session.conversation_id(), "conv1"); + + // Get storage back from session to reopen + let storage = session.into_storage(); // Open existing session - { - let session: RatchetSession = - RatchetSession::open(&mut storage, "conv1").unwrap(); - assert_eq!(session.state().msg_send, 0); - } + let session: RatchetSession = + RatchetSession::open(storage, "conv1").unwrap(); + assert_eq!(session.state().msg_send, 0); } #[test] fn test_session_encrypt_persists() { - let mut storage = create_test_storage(); + let storage = create_test_storage(); let shared_secret = [0x42; 32]; let bob_keypair = InstallationKeyPair::generate(); @@ -195,158 +203,120 @@ mod tests { RatchetState::init_sender(shared_secret, *bob_keypair.public()); // Create and encrypt - { - let mut session = RatchetSession::create(&mut storage, "conv1", alice).unwrap(); - session.encrypt_message(b"Hello").unwrap(); - assert_eq!(session.state().msg_send, 1); - } + let mut session = RatchetSession::create(storage, "conv1", alice).unwrap(); + session.encrypt_message(b"Hello").unwrap(); + assert_eq!(session.state().msg_send, 1); + + // Get storage back and reopen + let storage = session.into_storage(); // Reopen - state should be persisted - { - let session: RatchetSession = - RatchetSession::open(&mut storage, "conv1").unwrap(); - assert_eq!(session.state().msg_send, 1); - } + let session: RatchetSession = + RatchetSession::open(storage, "conv1").unwrap(); + assert_eq!(session.state().msg_send, 1); } #[test] fn test_session_full_conversation() { - let mut storage = create_test_storage(); + // Use separate in-memory storages for alice and bob (simulates different devices) + let alice_storage = create_test_storage(); + let bob_storage = create_test_storage(); let shared_secret = [0x42; 32]; let bob_keypair = InstallationKeyPair::generate(); - let alice: RatchetState = - RatchetState::init_sender(shared_secret, *bob_keypair.public()); - let bob: RatchetState = + let alice_state: RatchetState = + RatchetState::init_sender(shared_secret, bob_keypair.public().clone()); + let bob_state: RatchetState = RatchetState::init_receiver(shared_secret, bob_keypair); // Alice sends - let (ct, header) = { - let mut session = RatchetSession::create(&mut storage, "alice", alice).unwrap(); - session.encrypt_message(b"Hello Bob").unwrap() - }; + let mut alice_session = RatchetSession::create(alice_storage, "conv", alice_state).unwrap(); + let (ct, header) = alice_session.encrypt_message(b"Hello Bob").unwrap(); // Bob receives - let plaintext = { - let mut session = RatchetSession::create(&mut storage, "bob", bob).unwrap(); - session.decrypt_message(&ct, header).unwrap() - }; + let mut bob_session = RatchetSession::create(bob_storage, "conv", bob_state).unwrap(); + let plaintext = bob_session.decrypt_message(&ct, header).unwrap(); assert_eq!(plaintext, b"Hello Bob"); // Bob replies - let (ct2, header2) = { - let mut session: RatchetSession = - RatchetSession::open(&mut storage, "bob").unwrap(); - session.encrypt_message(b"Hi Alice").unwrap() - }; + let (ct2, header2) = bob_session.encrypt_message(b"Hi Alice").unwrap(); // Alice receives - let plaintext2 = { - let mut session: RatchetSession = - RatchetSession::open(&mut storage, "alice").unwrap(); - session.decrypt_message(&ct2, header2).unwrap() - }; + let plaintext2 = alice_session.decrypt_message(&ct2, header2).unwrap(); assert_eq!(plaintext2, b"Hi Alice"); } #[test] fn test_session_open_or_create() { - let mut storage = create_test_storage(); + let storage = create_test_storage(); let shared_secret = [0x42; 32]; let bob_keypair = InstallationKeyPair::generate(); let bob_pub = *bob_keypair.public(); // First call creates - { - let session: RatchetSession = RatchetSession::create_sender_session( - &mut storage, - "conv1", - shared_secret, - bob_pub, - ) - .unwrap(); - assert_eq!(session.state().msg_send, 0); - } - - // Second call opens existing - { - let mut session: RatchetSession = - RatchetSession::open(&mut storage, "conv1").unwrap(); - session.encrypt_message(b"test").unwrap(); - } + let session: RatchetSession = + RatchetSession::create_sender_session(storage, "conv1", shared_secret, bob_pub.clone()) + .unwrap(); + assert_eq!(session.state().msg_send, 0); + let storage = session.into_storage(); + + // Second call opens existing and encrypts + let mut session: RatchetSession = + RatchetSession::open(storage, "conv1").unwrap(); + session.encrypt_message(b"test").unwrap(); + let storage = session.into_storage(); // Verify persistence - { - let session: RatchetSession = - RatchetSession::open(&mut storage, "conv1").unwrap(); - assert_eq!(session.state().msg_send, 1); - } + let session: RatchetSession = + RatchetSession::open(storage, "conv1").unwrap(); + assert_eq!(session.state().msg_send, 1); } #[test] fn test_create_sender_session_fails_when_conversation_exists() { - let mut storage = create_test_storage(); + let storage = create_test_storage(); let shared_secret = [0x42; 32]; let bob_keypair = InstallationKeyPair::generate(); let bob_pub = *bob_keypair.public(); // First creation succeeds - { - let _session: RatchetSession = RatchetSession::create_sender_session( - &mut storage, - "conv1", - shared_secret, - bob_pub, - ) - .unwrap(); - } + let session: RatchetSession = + RatchetSession::create_sender_session(storage, "conv1", shared_secret, bob_pub.clone()) + .unwrap(); + let storage = session.into_storage(); // Second creation should fail with ConversationAlreadyExists - { - let result: Result, _> = - RatchetSession::create_sender_session( - &mut storage, - "conv1", - shared_secret, - bob_pub, - ); - - assert!(matches!(result, Err(SessionError::ConvAlreadyExists(_)))); - } + let result: Result, _> = + RatchetSession::create_sender_session(storage, "conv1", shared_secret, bob_pub.clone()); + + assert!(matches!(result, Err(SessionError::ConvAlreadyExists(_)))); } #[test] fn test_create_receiver_session_fails_when_conversation_exists() { - let mut storage = create_test_storage(); + let storage = create_test_storage(); let shared_secret = [0x42; 32]; let bob_keypair = InstallationKeyPair::generate(); // First creation succeeds - { - let _session: RatchetSession = RatchetSession::create_receiver_session( - &mut storage, + let session: RatchetSession = + RatchetSession::create_receiver_session(storage, "conv1", shared_secret, bob_keypair) + .unwrap(); + let storage = session.into_storage(); + + // Second creation should fail with ConversationAlreadyExists + let another_keypair = InstallationKeyPair::generate(); + let result: Result, _> = + RatchetSession::create_receiver_session( + storage, "conv1", shared_secret, - bob_keypair, - ) - .unwrap(); - } + another_keypair, + ); - // Second creation should fail with ConversationAlreadyExists - { - let another_keypair = InstallationKeyPair::generate(); - let result: Result, _> = - RatchetSession::create_receiver_session( - &mut storage, - "conv1", - shared_secret, - another_keypair, - ); - - assert!(matches!(result, Err(SessionError::ConvAlreadyExists(_)))); - } + assert!(matches!(result, Err(SessionError::ConvAlreadyExists(_)))); } } diff --git a/storage/src/sqlite.rs b/storage/src/sqlite.rs index 4d42e9d..8449532 100644 --- a/storage/src/sqlite.rs +++ b/storage/src/sqlite.rs @@ -8,8 +8,11 @@ use crate::StorageError; /// Configuration for SQLite storage. #[derive(Debug, Clone)] pub enum StorageConfig { - /// In-memory database (for testing). + /// In-memory database (isolated, for simple testing). InMemory, + /// Shared in-memory database with a name (multiple connections share data). + /// Use this when you need multiple storage instances to share the same in-memory DB. + SharedInMemory(String), /// File-based SQLite database. File(String), /// SQLCipher encrypted database. @@ -29,6 +32,17 @@ impl SqliteDb { pub fn new(config: StorageConfig) -> Result { let conn = match config { StorageConfig::InMemory => Connection::open_in_memory()?, + StorageConfig::SharedInMemory(ref name) => { + // Use URI mode to create a shared in-memory database + // Multiple connections with the same name share the same data + let uri = format!("file:{}?mode=memory&cache=shared", name); + Connection::open_with_flags( + &uri, + rusqlite::OpenFlags::SQLITE_OPEN_URI + | rusqlite::OpenFlags::SQLITE_OPEN_READ_WRITE + | rusqlite::OpenFlags::SQLITE_OPEN_CREATE, + )? + } StorageConfig::File(ref path) => Connection::open(path)?, StorageConfig::Encrypted { ref path, ref key } => { let conn = Connection::open(path)?; From e099d5fd15512ad5fbfde4150ea05e09824d5c2d Mon Sep 17 00:00:00 2001 From: kaichaosun Date: Fri, 27 Feb 2026 14:09:18 +0800 Subject: [PATCH 02/12] fix: db types conversion --- conversations/src/identity.rs | 7 +++++++ conversations/src/storage/db.rs | 29 ++++++++++++++++++----------- conversations/src/storage/types.rs | 12 ++++++++---- storage/src/errors.rs | 4 ++++ 4 files changed, 37 insertions(+), 15 deletions(-) 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/storage/db.rs b/conversations/src/storage/db.rs index 3bd0085..84ced02 100644 --- a/conversations/src/storage/db.rs +++ b/conversations/src/storage/db.rs @@ -12,6 +12,7 @@ const CHAT_SCHEMA: &str = " -- 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 ); @@ -61,10 +62,12 @@ impl ChatStorage { /// Saves the identity (secret key). pub fn save_identity(&mut self, identity: &Identity) -> Result<(), StorageError> { - let record = IdentityRecord::from(identity); self.db.connection().execute( - "INSERT OR REPLACE INTO identity (id, secret_key) VALUES (1, ?1)", - params![record.secret_key.as_slice()], + "INSERT OR REPLACE INTO identity (id, name, secret_key) VALUES (1, ?1, ?2)", + params![ + identity.get_name(), + identity.secret().DANGER_to_bytes().as_slice() + ], )?; Ok(()) } @@ -74,19 +77,23 @@ impl ChatStorage { let mut stmt = self .db .connection() - .prepare("SELECT secret_key FROM identity WHERE id = 1")?; + .prepare("SELECT name, secret_key FROM identity WHERE id = 1")?; let result = stmt.query_row([], |row| { - let secret_key: Vec = row.get(0)?; - Ok(secret_key) + let name: String = row.get(0)?; + let secret_key: Vec = row.get(1)?; + Ok((name, secret_key)) }); match result { - Ok(secret_key) => { + Ok((name, secret_key)) => { let bytes: [u8; 32] = secret_key .try_into() .map_err(|_| StorageError::InvalidData("Invalid secret key length".into()))?; - let record = IdentityRecord { secret_key: bytes }; + let record = IdentityRecord { + name, + secret_key: bytes, + }; Ok(Some(Identity::from(record))) } Err(RusqliteError::QueryReturnedNoRows) => Ok(None), @@ -229,13 +236,13 @@ mod tests { assert!(storage.load_identity().unwrap().is_none()); // Save identity - let identity = Identity::new(); - let address = identity.address(); + 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.address(), address); + assert_eq!(loaded.public_key(), pubkey); } #[test] diff --git a/conversations/src/storage/types.rs b/conversations/src/storage/types.rs index 553ac1b..bc0fa95 100644 --- a/conversations/src/storage/types.rs +++ b/conversations/src/storage/types.rs @@ -3,13 +3,16 @@ //! Note: Ratchet state types (RatchetStateRecord, SkippedKeyRecord) are in //! double_ratchets::storage module and handled by RatchetStorage. -use x25519_dalek::{PublicKey, StaticSecret}; +use x25519_dalek::PublicKey; +use crate::crypto::PrivateKey; use crate::identity::Identity; /// Record for storing identity (secret key). #[derive(Debug)] pub struct IdentityRecord { + /// The identity name. + pub name: String, /// The secret key bytes (32 bytes). pub secret_key: [u8; 32], } @@ -17,15 +20,16 @@ pub struct IdentityRecord { impl From<&Identity> for IdentityRecord { fn from(identity: &Identity) -> Self { Self { - secret_key: identity.secret().to_bytes(), + name: identity.get_name().to_string(), + secret_key: identity.secret().DANGER_to_bytes(), } } } impl From for Identity { fn from(record: IdentityRecord) -> Self { - let secret = StaticSecret::from(record.secret_key); - Identity::from_secret(secret) + let secret = PrivateKey::from(record.secret_key); + Identity::from_secret(record.name, secret) } } 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 { From f4c08bd04816cd1ffad0d3fda517cf51ad4111f5 Mon Sep 17 00:00:00 2001 From: kaichaosun Date: Fri, 27 Feb 2026 14:23:13 +0800 Subject: [PATCH 03/12] feat: run migrations from sql files --- conversations/src/storage/db.rs | 38 +++------------- conversations/src/storage/migrations.rs | 44 +++++++++++++++++++ .../storage/migrations/001_initial_schema.sql | 27 ++++++++++++ conversations/src/storage/mod.rs | 1 + storage/src/lib.rs | 2 +- 5 files changed, 78 insertions(+), 34 deletions(-) create mode 100644 conversations/src/storage/migrations.rs create mode 100644 conversations/src/storage/migrations/001_initial_schema.sql diff --git a/conversations/src/storage/db.rs b/conversations/src/storage/db.rs index 84ced02..807347b 100644 --- a/conversations/src/storage/db.rs +++ b/conversations/src/storage/db.rs @@ -3,38 +3,10 @@ use storage::{RusqliteError, SqliteDb, StorageConfig, StorageError, params}; use x25519_dalek::StaticSecret; +use super::migrations; use super::types::{ChatRecord, IdentityRecord}; use crate::identity::Identity; -/// Schema for chat storage tables. -/// Note: Ratchet state is stored by double_ratchets::RatchetStorage separately. -const CHAT_SCHEMA: &str = " - -- 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 - ); - - -- Inbox ephemeral keys for handshakes - CREATE TABLE IF NOT EXISTS inbox_keys ( - public_key_hex TEXT PRIMARY KEY, - secret_key BLOB NOT NULL, - created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) - ); - - -- Chat metadata - CREATE TABLE IF NOT EXISTS chats ( - chat_id TEXT PRIMARY KEY, - chat_type TEXT NOT NULL, - remote_public_key BLOB, - remote_address TEXT NOT NULL, - created_at INTEGER NOT NULL - ); - - CREATE INDEX IF NOT EXISTS idx_chats_type ON chats(chat_type); -"; - /// Chat-specific storage operations. /// /// This struct wraps a SqliteDb and provides domain-specific @@ -49,12 +21,12 @@ impl ChatStorage { /// Creates a new ChatStorage with the given configuration. pub fn new(config: StorageConfig) -> Result { let db = SqliteDb::new(config)?; - Self::run_migration(db) + Self::run_migrations(db) } - /// Creates a new chat storage with the given database. - fn run_migration(db: SqliteDb) -> Result { - db.connection().execute_batch(CHAT_SCHEMA)?; + /// Applies all migrations and returns the storage instance. + fn run_migrations(db: SqliteDb) -> Result { + migrations::apply_migrations(db.connection())?; Ok(Self { db }) } diff --git a/conversations/src/storage/migrations.rs b/conversations/src/storage/migrations.rs new file mode 100644 index 0000000..41b3cb4 --- /dev/null +++ b/conversations/src/storage/migrations.rs @@ -0,0 +1,44 @@ +//! Database migrations module. +//! +//! SQL migrations are embedded at compile time and applied in order. + +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: &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 + conn.execute_batch(sql)?; + + // Record migration + conn.execute("INSERT INTO _migrations (name) VALUES (?1)", [name])?; + } + } + + 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..70b5359 --- /dev/null +++ b/conversations/src/storage/migrations/001_initial_schema.sql @@ -0,0 +1,27 @@ +-- 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 +); + +-- Inbox ephemeral keys for handshakes +CREATE TABLE IF NOT EXISTS inbox_keys ( + public_key_hex TEXT PRIMARY KEY, + secret_key BLOB NOT NULL, + created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) +); + +-- Chat metadata +CREATE TABLE IF NOT EXISTS chats ( + chat_id TEXT PRIMARY KEY, + chat_type TEXT NOT NULL, + remote_public_key BLOB, + remote_address TEXT NOT NULL, + created_at INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_chats_type ON chats(chat_type); diff --git a/conversations/src/storage/mod.rs b/conversations/src/storage/mod.rs index 5153dbf..5d33d87 100644 --- a/conversations/src/storage/mod.rs +++ b/conversations/src/storage/mod.rs @@ -7,6 +7,7 @@ //! handles all storage operations automatically. mod db; +mod migrations; pub(crate) mod types; pub(crate) use db::ChatStorage; 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}; From 37eb2749b2be9ec95c0d1e6a0f92f236417682b0 Mon Sep 17 00:00:00 2001 From: kaichaosun Date: Fri, 27 Feb 2026 14:32:47 +0800 Subject: [PATCH 04/12] feat: persist identity --- conversations/src/api.rs | 22 +++++++++++ conversations/src/context.rs | 76 +++++++++++++++++++++++++++++++++--- 2 files changed, 93 insertions(+), 5 deletions(-) diff --git a/conversations/src/api.rs b/conversations/src/api.rs index bd1e300..8ba81b9 100644 --- a/conversations/src/api.rs +++ b/conversations/src/api.rs @@ -13,6 +13,8 @@ use safer_ffi::{ prelude::{c_slice, repr_c}, }; +use storage::StorageConfig; + use crate::{ context::{Context, Introduction}, errors::ChatError, @@ -54,6 +56,26 @@ 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 +/// +/// # Returns +/// Opaque handle to the context. Must be freed with destroy_context() +#[ffi_export] +pub fn create_context_with_storage( + name: repr_c::String, + db_path: repr_c::String, +) -> repr_c::Box { + let config = StorageConfig::File(db_path.to_string()); + let ctx = Context::open(&*name, config).expect("failed to open context with storage"); + Box::new(ContextHandle(ctx)).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..87a68fe 100644 --- a/conversations/src/context.rs +++ b/conversations/src/context.rs @@ -1,34 +1,73 @@ 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, StorageError}, types::{AddressedEnvelope, ContentData}, }; pub use crate::conversation::ConversationIdOwned; pub use crate::inbox::Introduction; +/// Error type for Context operations. +#[derive(Debug, thiserror::Error)] +pub enum ContextError { + #[error("chat error: {0}")] + Chat(#[from] ChatError), + + #[error("storage error: {0}")] + Storage(#[from] StorageError), +} + // This is the main entry point to the conversations api. // Ctx manages lifetimes of objects to process and generate payloads. pub struct Context { _identity: Rc, store: ConversationStore, inbox: Inbox, + 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 +234,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"); + } } From 3a9ddadc887b777d5bc49d5d4a53518123627f90 Mon Sep 17 00:00:00 2001 From: kaichaosun Date: Fri, 27 Feb 2026 14:48:53 +0800 Subject: [PATCH 05/12] fix: revert double ratchet storage refactor --- conversations/src/chat.rs | 615 -------------------- conversations/src/conversation/privatev1.rs | 48 +- conversations/src/lib.rs | 1 - conversations/src/storage/db.rs | 145 +---- conversations/src/storage/mod.rs | 7 - conversations/src/storage/types.rs | 37 -- double-ratchets/src/storage/db.rs | 6 - double-ratchets/src/storage/session.rs | 202 ++++--- 8 files changed, 119 insertions(+), 942 deletions(-) delete mode 100644 conversations/src/chat.rs diff --git a/conversations/src/chat.rs b/conversations/src/chat.rs deleted file mode 100644 index 19b9be1..0000000 --- a/conversations/src/chat.rs +++ /dev/null @@ -1,615 +0,0 @@ -//! ChatManager with integrated SQLite persistence. -//! -//! This is the main entry point for the conversations API. It handles all -//! storage operations internally - users don't need to interact with storage directly. - -use std::rc::Rc; - -use double_ratchets::storage::RatchetStorage; -use prost::Message; - -use crate::{ - conversation::PrivateV1Convo, - conversation::{Convo, Id}, - errors::ChatError, - identity::Identity, - inbox::{Inbox, Introduction}, - proto, - storage::{ChatRecord, ChatStorage, StorageError}, - types::{AddressedEnvelope, ContentData}, -}; - -// Re-export StorageConfig from storage crate for convenience -pub use storage::StorageConfig; - -/// Error type for ChatManager operations. -#[derive(Debug, thiserror::Error)] -pub enum ChatManagerError { - #[error("chat error: {0}")] - Chat(#[from] ChatError), - - #[error("storage error: {0}")] - Storage(#[from] StorageError), - - #[error("chat not found: {0}")] - ChatNotFound(String), -} - -/// ChatManager is the main entry point for the chat API. -/// -/// It manages identity, inbox, and chats with all state persisted to SQLite. -/// Chats are loaded from storage on each operation - no in-memory caching. -/// Uses a single shared database for both chat metadata and ratchet state. -/// -/// # Example -/// -/// ```ignore -/// // Create a new chat manager with encrypted storage -/// let mut chat = ChatManager::open(StorageConfig::Encrypted { -/// path: "chat.db".into(), -/// key: "my_secret_key".into(), -/// })?; -/// -/// // Get your address to share with others -/// println!("My address: {}", chat.local_address()); -/// -/// // Create an intro bundle to share -/// let intro = chat.create_intro_bundle()?; -/// -/// // Start a chat with someone -/// let (chat_id, envelopes) = chat.start_private_chat(&their_intro, "Hello!")?; -/// // Send envelopes over the network... -/// -/// // Send more messages -/// let envelopes = chat.send_message(&chat_id, b"How are you?")?; -/// ``` -pub struct ChatManager { - identity: Rc, - inbox: Inbox, - /// Storage for chat metadata (identity, inbox keys, chat records). - storage: ChatStorage, - /// Storage config for creating ratchet storage instances. - /// For file/encrypted databases, SQLite handles connection efficiently. - /// For in-memory testing, use SharedInMemory to share data. - storage_config: StorageConfig, -} - -impl ChatManager { - /// Opens or creates a ChatManager with the given storage configuration. - /// - /// If an identity exists in storage, it will be restored. - /// Otherwise, a new identity will be created and saved. - pub fn open(config: StorageConfig) -> Result { - let mut storage = ChatStorage::new(config.clone())?; - - // Load or create identity - let identity = if let Some(identity) = storage.load_identity()? { - identity - } else { - let identity = Identity::new("default"); - storage.save_identity(&identity)?; - identity - }; - - let identity = Rc::new(identity); - let inbox = Inbox::new(Rc::clone(&identity)); - - Ok(Self { - identity, - inbox, - storage, - storage_config: config, - }) - } - - /// Creates a new in-memory ChatManager (for testing). - /// - /// Uses a shared in-memory SQLite database so that multiple storage - /// instances within the same ChatManager share data. - /// - /// The `db_name` should be unique per ChatManager instance to avoid - /// sharing data between different users. - pub fn in_memory(db_name: &str) -> Result { - Self::open(StorageConfig::SharedInMemory(db_name.to_string())) - } - - /// Creates a new RatchetStorage instance using the stored config. - fn create_ratchet_storage(&self) -> Result { - Ok(RatchetStorage::with_config(self.storage_config.clone())?) - } - - /// Load a chat from storage. - fn load_chat(&self, chat_id: &str) -> Result { - let ratchet_storage = self.create_ratchet_storage()?; - if ratchet_storage.exists(chat_id)? { - let base_conv_id = chat_id.parse()?; - Ok(PrivateV1Convo::open(ratchet_storage, base_conv_id)?) - } else if self.storage.chat_exists(chat_id)? { - // Chat metadata exists but no ratchet state - data inconsistency - Err(ChatManagerError::ChatNotFound(format!( - "{} (corrupted: missing ratchet state)", - chat_id - ))) - } else { - Err(ChatManagerError::ChatNotFound(chat_id.to_string())) - } - } - - /// Get the local identity's public address. - /// - /// This address can be shared with others so they can identify you. - pub fn local_address(&self) -> String { - hex::encode(self.identity.public_key().as_bytes()) - } - - /// Create an introduction bundle that can be shared with others. - /// - /// Others can use this bundle to initiate a chat with you. - /// Share it via QR code, link, or any other out-of-band method. - /// - /// The ephemeral key is automatically persisted to storage. - pub fn create_intro_bundle(&mut self) -> Result { - let (pkb, secret) = self.inbox.create_bundle(); - let intro = Introduction::from(pkb); - - // Persist the ephemeral key - let public_key_hex = hex::encode(intro.ephemeral_key.as_bytes()); - self.storage.save_inbox_key(&public_key_hex, &secret)?; - - Ok(intro) - } - - /// Start a new private conversation with someone using their introduction bundle. - /// - /// Returns the chat ID and envelopes that must be delivered to the remote party. - /// The chat state is automatically persisted (via RatchetSession). - pub fn start_private_chat( - &mut self, - remote_bundle: &Introduction, - initial_message: &str, - ) -> Result<(String, Vec), ChatManagerError> { - // Create new storage for this conversation's RatchetSession - let ratchet_storage = self.create_ratchet_storage()?; - - let (convo, payloads) = self.inbox.invite_to_private_convo( - ratchet_storage, - remote_bundle, - initial_message.to_string(), - )?; - - let chat_id = convo.id().to_string(); - - let envelopes: Vec = payloads - .into_iter() - .map(|p| p.to_envelope(chat_id.clone())) - .collect(); - - // Persist chat metadata - let chat_record = ChatRecord::new_private( - chat_id.clone(), - remote_bundle.installation_key, - payloads_delivery_address(&envelopes), - ); - self.storage.save_chat(&chat_record)?; - - // Ratchet state is automatically persisted by RatchetSession - // convo is dropped here - state already saved - - Ok((chat_id, envelopes)) - } - - /// Send a message to an existing chat. - /// - /// Returns envelopes that must be delivered to chat participants. - pub fn send_message( - &mut self, - chat_id: &str, - content: &[u8], - ) -> Result, ChatManagerError> { - // Load chat from storage - let mut chat = self.load_chat(chat_id)?; - - let payloads = chat.send_message(content)?; - - // Ratchet state is automatically persisted by RatchetSession - - let remote_id = chat.remote_id(); - Ok(payloads - .into_iter() - .map(|p| p.to_envelope(remote_id.clone())) - .collect()) - } - - /// Handle an incoming payload from the network. - /// - /// This processes both inbox handshakes (to establish new chats) and - /// messages for existing chats. - /// - /// Returns the decrypted content if successful. - /// Any new chats or state changes are automatically persisted. - pub fn handle_incoming(&mut self, payload: &[u8]) -> Result { - // Try to decode as an envelope - if let Ok(envelope) = proto::EnvelopeV1::decode(payload) { - let chat_id = &envelope.conversation_hint; - - // Check if we have this chat - if so, route to it for decryption - if !chat_id.is_empty() && self.chat_exists(chat_id)? { - return self.receive_message(chat_id, &envelope.payload); - } - - // We don't have this chat - try to handle as inbox handshake - // Pass the conversation_hint so both parties use the same chat ID - return self.handle_inbox_handshake(chat_id, &envelope.payload); - } - - // Not a valid envelope format - Err(ChatManagerError::Chat(ChatError::Protocol( - "invalid envelope format".to_string(), - ))) - } - - /// Handle an inbox handshake to establish a new chat. - fn handle_inbox_handshake( - &mut self, - conversation_hint: &str, - payload: &[u8], - ) -> Result { - // Extract the ephemeral key hex from the payload - let key_hex = Inbox::extract_ephemeral_key_hex(payload)?; - - // Load the ephemeral key from storage - let ephemeral_key = self - .storage - .load_inbox_key(&key_hex)? - .ok_or_else(|| ChatManagerError::Chat(ChatError::UnknownEphemeralKey()))?; - - let ratchet_storage = self.create_ratchet_storage()?; - let result = - self.inbox - .handle_frame(ratchet_storage, conversation_hint, payload, &ephemeral_key)?; - - let chat_id = result.convo.id().to_string(); - - // Persist the new chat metadata - let chat_record = ChatRecord { - chat_id: chat_id.clone(), - chat_type: "private_v1".to_string(), - remote_public_key: Some(result.remote_public_key), - remote_address: hex::encode(result.remote_public_key), - created_at: crate::utils::timestamp_millis() as i64, - }; - self.storage.save_chat(&chat_record)?; - - // Delete the ephemeral key from storage after successful handshake - self.storage.delete_inbox_key(&key_hex)?; - - // Ratchet state is automatically persisted by RatchetSession - // result.convo is dropped here - state already saved - - Ok(ContentData { - conversation_id: chat_id, - data: result.initial_content.unwrap_or_default(), - }) - } - - /// Receive and decrypt a message for an existing chat. - /// - /// The payload should be the raw encrypted payload bytes. - pub fn receive_message( - &mut self, - chat_id: &str, - payload: &[u8], - ) -> Result { - // Load chat from storage - let mut chat = self.load_chat(chat_id)?; - - // Decode and decrypt the payload - let encrypted_payload = proto::EncryptedPayload::decode(payload).map_err(|e| { - ChatManagerError::Chat(ChatError::Protocol(format!("failed to decode: {}", e))) - })?; - - let frame = chat.decrypt(encrypted_payload)?; - let content = PrivateV1Convo::extract_content(&frame).unwrap_or_default(); - - // Ratchet state is automatically persisted by RatchetSession - - Ok(ContentData { - conversation_id: chat_id.to_string(), - data: content, - }) - } - - /// List all chat IDs from storage. - pub fn list_chats(&self) -> Result, ChatManagerError> { - Ok(self.storage.list_chat_ids()?) - } - - /// Check if a chat exists in storage. - pub fn chat_exists(&self, chat_id: &str) -> Result { - Ok(self.storage.chat_exists(chat_id)?) - } - - /// Delete a chat from storage. - pub fn delete_chat(&mut self, chat_id: &str) -> Result<(), ChatManagerError> { - self.storage.delete_chat(chat_id)?; - // Also delete ratchet state from double-ratchets storage - if let Ok(mut ratchet_storage) = self.create_ratchet_storage() { - let _ = ratchet_storage.delete(chat_id); - } - Ok(()) - } -} - -/// Extract delivery address from envelopes (helper function). -fn payloads_delivery_address(envelopes: &[AddressedEnvelope]) -> String { - envelopes - .first() - .map(|e| e.delivery_address.clone()) - .unwrap_or_else(|| "unknown".to_string()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_create_chat_manager() { - let manager = ChatManager::in_memory("test1").unwrap(); - assert!(!manager.local_address().is_empty()); - } - - #[test] - fn test_identity_persistence() { - let manager = ChatManager::in_memory("test2").unwrap(); - let address = manager.local_address(); - - // Identity should be persisted - let loaded = manager.storage.load_identity().unwrap(); - assert!(loaded.is_some()); - assert_eq!(loaded.unwrap().address(), address); - } - - #[test] - fn test_create_intro_bundle() { - let mut manager = ChatManager::in_memory("test3").unwrap(); - let bundle = manager.create_intro_bundle(); - assert!(bundle.is_ok()); - } - - #[test] - fn test_start_private_chat() { - let mut alice = ChatManager::in_memory("alice1").unwrap(); - let mut bob = ChatManager::in_memory("bob1").unwrap(); - - // Bob creates an intro bundle - let bob_intro = bob.create_intro_bundle().unwrap(); - - // Alice starts a chat with Bob - let result = alice.start_private_chat(&bob_intro, "Hello Bob!"); - assert!(result.is_ok()); - - let (chat_id, envelopes) = result.unwrap(); - assert!(!chat_id.is_empty()); - assert!(!envelopes.is_empty()); - - // Chat should be persisted - let stored = alice.list_chats().unwrap(); - assert!(stored.contains(&chat_id)); - } - - #[test] - fn test_inbox_key_persistence() { - let mut manager = ChatManager::in_memory("test4").unwrap(); - - // Create intro bundle (should persist ephemeral key) - let intro = manager.create_intro_bundle().unwrap(); - let key_hex = hex::encode(intro.ephemeral_key.as_bytes()); - - // Key should be persisted - load it directly - let loaded_key = manager.storage.load_inbox_key(&key_hex).unwrap(); - assert!(loaded_key.is_some()); - } - - #[test] - fn test_chat_exists() { - let mut alice = ChatManager::in_memory("alice2").unwrap(); - let mut bob = ChatManager::in_memory("bob2").unwrap(); - - let bob_intro = bob.create_intro_bundle().unwrap(); - let (chat_id, _) = alice.start_private_chat(&bob_intro, "Hello!").unwrap(); - - // Chat should exist - assert!(alice.chat_exists(&chat_id).unwrap()); - assert!(!alice.chat_exists("nonexistent").unwrap()); - } - - #[test] - fn test_delete_chat() { - let mut alice = ChatManager::in_memory("alice3").unwrap(); - let mut bob = ChatManager::in_memory("bob3").unwrap(); - - let bob_intro = bob.create_intro_bundle().unwrap(); - let (chat_id, _) = alice.start_private_chat(&bob_intro, "Hello!").unwrap(); - - // Delete chat - alice.delete_chat(&chat_id).unwrap(); - - // Chat should no longer exist - assert!(!alice.chat_exists(&chat_id).unwrap()); - assert!(alice.list_chats().unwrap().is_empty()); - } - - #[test] - fn test_ratchet_state_persistence() { - use tempfile::tempdir; - - // Create a temporary directory for the database - let dir = tempdir().unwrap(); - let db_path = dir.path().join("test.db"); - - let mut bob = ChatManager::in_memory("bob4").unwrap(); - let bob_intro = bob.create_intro_bundle().unwrap(); - - let chat_id; - - // Scope 1: Create chat and send messages - { - let mut alice = - ChatManager::open(StorageConfig::File(db_path.to_str().unwrap().to_string())) - .unwrap(); - - let result = alice.start_private_chat(&bob_intro, "Message 1").unwrap(); - chat_id = result.0; - - // Send more messages - this advances the ratchet - alice.send_message(&chat_id, b"Message 2").unwrap(); - alice.send_message(&chat_id, b"Message 3").unwrap(); - - // Chat should be in storage - assert!(alice.chat_exists(&chat_id).unwrap()); - } - // alice is dropped here, simulating app close - - // Scope 2: Reopen and verify chat is restored - { - let mut alice2 = - ChatManager::open(StorageConfig::File(db_path.to_str().unwrap().to_string())) - .unwrap(); - - // Chat should still be in storage - assert!(alice2.list_chats().unwrap().contains(&chat_id)); - - // Send another message - this will load the chat and advance ratchet - let result = alice2.send_message(&chat_id, b"Message 4"); - assert!(result.is_ok(), "Should be able to send after restore"); - } - } - - #[test] - fn test_full_message_roundtrip() { - use tempfile::tempdir; - - // Use temp files instead of in-memory for proper storage sharing - let dir = tempdir().unwrap(); - let alice_db = dir.path().join("alice.db"); - let bob_db = dir.path().join("bob.db"); - - let mut alice = - ChatManager::open(StorageConfig::File(alice_db.to_str().unwrap().to_string())).unwrap(); - let mut bob = - ChatManager::open(StorageConfig::File(bob_db.to_str().unwrap().to_string())).unwrap(); - - // Bob creates an intro bundle and shares it with Alice - let bob_intro = bob.create_intro_bundle().unwrap(); - - // Alice starts a chat with Bob and sends "Hello!" - let (alice_chat_id, envelopes) = - alice.start_private_chat(&bob_intro, "Hello Bob!").unwrap(); - - // Verify Alice has the chat - assert!(alice.chat_exists(&alice_chat_id).unwrap()); - assert_eq!(alice.list_chats().unwrap().len(), 1); - - // Simulate network delivery: Bob receives the envelope - let envelope = envelopes.first().unwrap(); - let content = bob.handle_incoming(&envelope.data).unwrap(); - - // Bob should have received the message - assert_eq!(content.data, b"Hello Bob!"); - - // Bob should now have a chat - assert_eq!(bob.list_chats().unwrap().len(), 1); - let bob_chat_id = bob.list_chats().unwrap().first().unwrap().clone(); - - // Bob replies to Alice - let bob_reply_envelopes = bob.send_message(&bob_chat_id, b"Hi Alice!").unwrap(); - assert!(!bob_reply_envelopes.is_empty()); - - // Alice receives Bob's reply - let bob_reply = bob_reply_envelopes.first().unwrap(); - let alice_received = alice.handle_incoming(&bob_reply.data).unwrap(); - - assert_eq!(alice_received.data, b"Hi Alice!"); - assert_eq!(alice_received.conversation_id, alice_chat_id); - - // Continue the conversation - Alice sends another message - let alice_envelopes = alice.send_message(&alice_chat_id, b"How are you?").unwrap(); - let alice_msg = alice_envelopes.first().unwrap(); - let bob_received = bob.handle_incoming(&alice_msg.data).unwrap(); - - assert_eq!(bob_received.data, b"How are you?"); - - // Bob replies again - let bob_envelopes = bob - .send_message(&bob_chat_id, b"I'm good, thanks!") - .unwrap(); - let bob_msg = bob_envelopes.first().unwrap(); - let alice_received2 = alice.handle_incoming(&bob_msg.data).unwrap(); - - assert_eq!(alice_received2.data, b"I'm good, thanks!"); - } - - #[test] - fn test_message_persistence_across_sessions() { - use tempfile::tempdir; - - let dir = tempdir().unwrap(); - let alice_db = dir.path().join("alice.db"); - let bob_db = dir.path().join("bob.db"); - - let alice_chat_id; - let bob_chat_id; - let bob_intro; - - // Phase 1: Establish chat - { - let mut alice = - ChatManager::open(StorageConfig::File(alice_db.to_str().unwrap().to_string())) - .unwrap(); - let mut bob = - ChatManager::open(StorageConfig::File(bob_db.to_str().unwrap().to_string())) - .unwrap(); - - bob_intro = bob.create_intro_bundle().unwrap(); - let (chat_id, envelopes) = alice.start_private_chat(&bob_intro, "Initial").unwrap(); - alice_chat_id = chat_id; - - // Bob receives - let envelope = envelopes.first().unwrap(); - let content = bob.handle_incoming(&envelope.data).unwrap(); - assert_eq!(content.data, b"Initial"); - bob_chat_id = bob.list_chats().unwrap().first().unwrap().clone(); - } - // Both dropped - simulates app restart - - // Phase 2: Continue conversation after restart - { - let mut alice = - ChatManager::open(StorageConfig::File(alice_db.to_str().unwrap().to_string())) - .unwrap(); - let mut bob = - ChatManager::open(StorageConfig::File(bob_db.to_str().unwrap().to_string())) - .unwrap(); - - // Both should have persisted chats - assert!(alice.list_chats().unwrap().contains(&alice_chat_id)); - assert!(bob.list_chats().unwrap().contains(&bob_chat_id)); - - // Alice sends a message (chat loads from storage) - let envelopes = alice - .send_message(&alice_chat_id, b"After restart") - .unwrap(); - - // Bob receives (chat loads from storage) - let envelope = envelopes.first().unwrap(); - let content = bob.handle_incoming(&envelope.data).unwrap(); - assert_eq!(content.data, b"After restart"); - - // Bob replies - let bob_envelopes = bob.send_message(&bob_chat_id, b"Still works!").unwrap(); - let bob_msg = bob_envelopes.first().unwrap(); - let alice_received = alice.handle_incoming(&bob_msg.data).unwrap(); - assert_eq!(alice_received.data, b"Still works!"); - } - } -} diff --git a/conversations/src/conversation/privatev1.rs b/conversations/src/conversation/privatev1.rs index b924d3e..0b8042e 100644 --- a/conversations/src/conversation/privatev1.rs +++ b/conversations/src/conversation/privatev1.rs @@ -7,12 +7,9 @@ use chat_proto::logoschat::{ encryption::{Doubleratchet, EncryptedPayload, encrypted_payload::Encryption}, }; use crypto::{PrivateKey, PublicKey, SymmetricKey32}; -use double_ratchets::{Header, InstallationKeyPair, RatchetSession, RatchetState, RatchetStorage}; +use double_ratchets::{Header, InstallationKeyPair, RatchetState}; use prost::{Message, bytes::Bytes}; -use std::{ - fmt::{self, Debug, Display, Formatter}, - str::FromStr, -}; +use std::fmt::Debug; use crate::{ conversation::{ChatError, ConversationId, Convo, Id}, @@ -55,34 +52,10 @@ impl BaseConvoId { } } -impl Display for BaseConvoId { - fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { - write!(f, "{}", hex::encode(self.0)) - } -} - -impl FromStr for BaseConvoId { - type Err = ChatError; - - fn from_str(s: &str) -> Result { - let bytes = hex::decode(s).map_err(|_| ChatError::BadParsing("base conversation ID"))?; - - if bytes.len() != 18 { - return Err(ChatError::BadParsing("base conversation ID")); - } - - let mut arr = [0u8; 18]; - arr.copy_from_slice(&bytes); - - Ok(Self(arr)) - } -} - pub struct PrivateV1Convo { local_convo_id: String, remote_convo_id: String, dr_state: RatchetState, - session: Option, } impl PrivateV1Convo { @@ -101,7 +74,6 @@ impl PrivateV1Convo { local_convo_id, remote_convo_id, dr_state, - session: None, } } @@ -121,25 +93,9 @@ impl PrivateV1Convo { local_convo_id, remote_convo_id, dr_state, - session: None, } } - /// Open an existing conversation from storage. - pub fn open(storage: RatchetStorage, base_convo_id: BaseConvoId) -> Result { - let local_convo_id = base_convo_id.id_for_participant(Role::Responder); - let remote_convo_id = base_convo_id.id_for_participant(Role::Initiator); - - let session = RatchetSession::open(storage, &local_convo_id)?; - - Ok(Self { - local_convo_id, - remote_convo_id, - dr_state: session.state().clone(), - session: Some(session), - }) - } - fn encrypt(&mut self, frame: PrivateV1Frame) -> EncryptedPayload { let encoded_bytes = frame.encode_to_vec(); let (cipher_text, header) = self.dr_state.encrypt_message(&encoded_bytes); diff --git a/conversations/src/lib.rs b/conversations/src/lib.rs index b82bb22..e13c3fa 100644 --- a/conversations/src/lib.rs +++ b/conversations/src/lib.rs @@ -1,5 +1,4 @@ mod api; -mod chat; mod context; mod conversation; mod crypto; diff --git a/conversations/src/storage/db.rs b/conversations/src/storage/db.rs index 807347b..86f40fd 100644 --- a/conversations/src/storage/db.rs +++ b/conversations/src/storage/db.rs @@ -1,10 +1,9 @@ //! Chat-specific storage implementation. use storage::{RusqliteError, SqliteDb, StorageConfig, StorageError, params}; -use x25519_dalek::StaticSecret; use super::migrations; -use super::types::{ChatRecord, IdentityRecord}; +use super::types::IdentityRecord; use crate::identity::Identity; /// Chat-specific storage operations. @@ -72,128 +71,6 @@ impl ChatStorage { Err(e) => Err(e.into()), } } - - // ==================== Inbox Key Operations ==================== - - /// Saves an inbox ephemeral key. - pub fn save_inbox_key( - &mut self, - public_key_hex: &str, - secret: &StaticSecret, - ) -> Result<(), StorageError> { - self.db.connection().execute( - "INSERT OR REPLACE INTO inbox_keys (public_key_hex, secret_key) VALUES (?1, ?2)", - params![public_key_hex, secret.as_bytes().as_slice()], - )?; - Ok(()) - } - - /// Loads a single inbox ephemeral key by public key hex. - pub fn load_inbox_key( - &self, - public_key_hex: &str, - ) -> Result, StorageError> { - let mut stmt = self - .db - .connection() - .prepare("SELECT secret_key FROM inbox_keys WHERE public_key_hex = ?1")?; - - let result = stmt.query_row(params![public_key_hex], |row| { - let secret_key: Vec = row.get(0)?; - Ok(secret_key) - }); - - match result { - Ok(secret_key) => { - let bytes: [u8; 32] = secret_key - .try_into() - .map_err(|_| StorageError::InvalidData("Invalid secret key length".into()))?; - Ok(Some(StaticSecret::from(bytes))) - } - Err(RusqliteError::QueryReturnedNoRows) => Ok(None), - Err(e) => Err(e.into()), - } - } - - /// Deletes an inbox ephemeral key after it has been used. - pub fn delete_inbox_key(&mut self, public_key_hex: &str) -> Result<(), StorageError> { - self.db.connection().execute( - "DELETE FROM inbox_keys WHERE public_key_hex = ?1", - params![public_key_hex], - )?; - Ok(()) - } - - // ==================== Chat Metadata Operations ==================== - - /// Saves a chat record. - pub fn save_chat(&mut self, chat: &ChatRecord) -> Result<(), StorageError> { - self.db.connection().execute( - "INSERT OR REPLACE INTO chats (chat_id, chat_type, remote_public_key, remote_address, created_at) - VALUES (?1, ?2, ?3, ?4, ?5)", - params![ - chat.chat_id, - chat.chat_type, - chat.remote_public_key.as_ref().map(|k| k.as_slice()), - chat.remote_address, - chat.created_at, - ], - )?; - Ok(()) - } - - /// Lists all chat IDs. - pub fn list_chat_ids(&self) -> Result, StorageError> { - let mut stmt = self.db.connection().prepare("SELECT chat_id FROM chats")?; - let rows = stmt.query_map([], |row| row.get(0))?; - - let mut ids = Vec::new(); - for row in rows { - ids.push(row?); - } - - Ok(ids) - } - - /// Checks if a chat exists in storage. - pub fn chat_exists(&self, chat_id: &str) -> Result { - let mut stmt = self - .db - .connection() - .prepare("SELECT 1 FROM chats WHERE chat_id = ?1")?; - - let exists = stmt.exists(params![chat_id])?; - Ok(exists) - } - - /// Finds a chat by remote address. - /// Returns the chat_id if found, None otherwise. - #[allow(dead_code)] - pub fn find_chat_by_remote_address( - &self, - remote_address: &str, - ) -> Result, StorageError> { - let mut stmt = self - .db - .connection() - .prepare("SELECT chat_id FROM chats WHERE remote_address = ?1 LIMIT 1")?; - - let mut rows = stmt.query(params![remote_address])?; - if let Some(row) = rows.next()? { - Ok(Some(row.get(0)?)) - } else { - Ok(None) - } - } - - /// Deletes a chat record. - /// Note: Ratchet state must be deleted separately via RatchetStorage. - pub fn delete_chat(&mut self, chat_id: &str) -> Result<(), StorageError> { - self.db - .connection() - .execute("DELETE FROM chats WHERE chat_id = ?1", params![chat_id])?; - Ok(()) - } } #[cfg(test)] @@ -216,24 +93,4 @@ mod tests { let loaded = storage.load_identity().unwrap().unwrap(); assert_eq!(loaded.public_key(), pubkey); } - - #[test] - fn test_chat_roundtrip() { - let mut storage = ChatStorage::new(StorageConfig::InMemory).unwrap(); - - let secret = x25519_dalek::StaticSecret::random(); - let remote_key = x25519_dalek::PublicKey::from(&secret); - let chat = ChatRecord::new_private( - "chat_123".to_string(), - remote_key, - "delivery_addr".to_string(), - ); - - // Save chat - storage.save_chat(&chat).unwrap(); - - // List chats - let ids = storage.list_chat_ids().unwrap(); - assert_eq!(ids, vec!["chat_123"]); - } } diff --git a/conversations/src/storage/mod.rs b/conversations/src/storage/mod.rs index 5d33d87..7a32751 100644 --- a/conversations/src/storage/mod.rs +++ b/conversations/src/storage/mod.rs @@ -1,10 +1,4 @@ //! Storage module for persisting chat state. -//! -//! This module provides storage implementations for the chat manager state, -//! built on top of the shared `storage` crate. -//! -//! Note: This module is internal. Users should use `ChatManager` which -//! handles all storage operations automatically. mod db; mod migrations; @@ -12,4 +6,3 @@ pub(crate) mod types; pub(crate) use db::ChatStorage; pub(crate) use storage::StorageError; -pub(crate) use types::ChatRecord; diff --git a/conversations/src/storage/types.rs b/conversations/src/storage/types.rs index bc0fa95..d82a421 100644 --- a/conversations/src/storage/types.rs +++ b/conversations/src/storage/types.rs @@ -1,9 +1,4 @@ //! Storage record types for serialization/deserialization. -//! -//! Note: Ratchet state types (RatchetStateRecord, SkippedKeyRecord) are in -//! double_ratchets::storage module and handled by RatchetStorage. - -use x25519_dalek::PublicKey; use crate::crypto::PrivateKey; use crate::identity::Identity; @@ -32,35 +27,3 @@ impl From for Identity { Identity::from_secret(record.name, secret) } } - -/// Record for storing chat metadata. -/// Note: The actual double ratchet state is stored separately by RatchetStorage. -#[derive(Debug, Clone)] -pub struct ChatRecord { - /// Unique chat identifier. - pub chat_id: String, - /// Type of chat (e.g., "private_v1", "group_v1"). - pub chat_type: String, - /// Remote party's public key (for private chats). - pub remote_public_key: Option<[u8; 32]>, - /// Remote party's delivery address. - pub remote_address: String, - /// Creation timestamp (unix millis). - pub created_at: i64, -} - -impl ChatRecord { - pub fn new_private( - chat_id: String, - remote_public_key: PublicKey, - remote_address: String, - ) -> Self { - Self { - chat_id, - chat_type: "private_v1".to_string(), - remote_public_key: Some(remote_public_key.to_bytes()), - remote_address, - created_at: crate::utils::timestamp_millis() as i64, - } - } -} diff --git a/double-ratchets/src/storage/db.rs b/double-ratchets/src/storage/db.rs index a41d2bd..43b3f4b 100644 --- a/double-ratchets/src/storage/db.rs +++ b/double-ratchets/src/storage/db.rs @@ -47,12 +47,6 @@ pub struct RatchetStorage { } impl RatchetStorage { - /// Creates a new RatchetStorage with the given configuration. - pub fn with_config(config: storage::StorageConfig) -> Result { - let db = SqliteDb::new(config)?; - Self::run_migration(db) - } - /// Opens an existing encrypted database file. pub fn new(path: &str, key: &str) -> Result { let db = SqliteDb::sqlcipher(path.to_string(), key.to_string())?; diff --git a/double-ratchets/src/storage/session.rs b/double-ratchets/src/storage/session.rs index 2598d85..ea3cdfc 100644 --- a/double-ratchets/src/storage/session.rs +++ b/double-ratchets/src/storage/session.rs @@ -13,19 +13,16 @@ use super::RatchetStorage; /// A session wrapper that automatically persists ratchet state after operations. /// Provides rollback semantics - state is only saved if the operation succeeds. -/// -/// This struct owns its storage, making it easy to store in other structs -/// and use across multiple operations without lifetime concerns. -pub struct RatchetSession { - storage: RatchetStorage, +pub struct RatchetSession<'a, D: HkdfInfo + Clone = DefaultDomain> { + storage: &'a mut RatchetStorage, conversation_id: String, state: RatchetState, } -impl<'a, D: HkdfInfo + Clone> RatchetSession { +impl<'a, D: HkdfInfo + Clone> RatchetSession<'a, D> { /// Opens an existing session from storage. pub fn open( - storage: RatchetStorage, + storage: &'a mut RatchetStorage, conversation_id: impl Into, ) -> Result { let conversation_id = conversation_id.into(); @@ -39,7 +36,7 @@ impl<'a, D: HkdfInfo + Clone> RatchetSession { /// Creates a new session and persists the initial state. pub fn create( - mut storage: RatchetStorage, + storage: &'a mut RatchetStorage, conversation_id: impl Into, state: RatchetState, ) -> Result { @@ -54,7 +51,7 @@ impl<'a, D: HkdfInfo + Clone> RatchetSession { /// Initializes a new session as a sender and persists the initial state. pub fn create_sender_session( - storage: RatchetStorage, + storage: &'a mut RatchetStorage, conversation_id: &str, shared_secret: SharedSecret, remote_pub: PublicKey, @@ -68,7 +65,7 @@ impl<'a, D: HkdfInfo + Clone> RatchetSession { /// Initializes a new session as a receiver and persists the initial state. pub fn create_receiver_session( - storage: RatchetStorage, + storage: &'a mut RatchetStorage, conversation_id: &str, shared_secret: SharedSecret, dh_self: InstallationKeyPair, @@ -140,12 +137,6 @@ impl<'a, D: HkdfInfo + Clone> RatchetSession { &self.conversation_id } - /// Consumes the session and returns the underlying storage. - /// Useful when you need to reuse the storage for another session. - pub fn into_storage(self) -> RatchetStorage { - self.storage - } - /// Manually saves the current state. pub fn save(&mut self) -> Result<(), SessionError> { self.storage @@ -173,29 +164,30 @@ mod tests { #[test] fn test_session_create_and_open() { - let storage = create_test_storage(); + let mut storage = create_test_storage(); let shared_secret = [0x42; 32]; let bob_keypair = InstallationKeyPair::generate(); let alice: RatchetState = RatchetState::init_sender(shared_secret, *bob_keypair.public()); - // Create session - session takes ownership of storage - let session = RatchetSession::create(storage, "conv1", alice).unwrap(); - assert_eq!(session.conversation_id(), "conv1"); - - // Get storage back from session to reopen - let storage = session.into_storage(); + // Create session + { + let session = RatchetSession::create(&mut storage, "conv1", alice).unwrap(); + assert_eq!(session.conversation_id(), "conv1"); + } // Open existing session - let session: RatchetSession = - RatchetSession::open(storage, "conv1").unwrap(); - assert_eq!(session.state().msg_send, 0); + { + let session: RatchetSession = + RatchetSession::open(&mut storage, "conv1").unwrap(); + assert_eq!(session.state().msg_send, 0); + } } #[test] fn test_session_encrypt_persists() { - let storage = create_test_storage(); + let mut storage = create_test_storage(); let shared_secret = [0x42; 32]; let bob_keypair = InstallationKeyPair::generate(); @@ -203,120 +195,158 @@ mod tests { RatchetState::init_sender(shared_secret, *bob_keypair.public()); // Create and encrypt - let mut session = RatchetSession::create(storage, "conv1", alice).unwrap(); - session.encrypt_message(b"Hello").unwrap(); - assert_eq!(session.state().msg_send, 1); - - // Get storage back and reopen - let storage = session.into_storage(); + { + let mut session = RatchetSession::create(&mut storage, "conv1", alice).unwrap(); + session.encrypt_message(b"Hello").unwrap(); + assert_eq!(session.state().msg_send, 1); + } // Reopen - state should be persisted - let session: RatchetSession = - RatchetSession::open(storage, "conv1").unwrap(); - assert_eq!(session.state().msg_send, 1); + { + let session: RatchetSession = + RatchetSession::open(&mut storage, "conv1").unwrap(); + assert_eq!(session.state().msg_send, 1); + } } #[test] fn test_session_full_conversation() { - // Use separate in-memory storages for alice and bob (simulates different devices) - let alice_storage = create_test_storage(); - let bob_storage = create_test_storage(); + let mut storage = create_test_storage(); let shared_secret = [0x42; 32]; let bob_keypair = InstallationKeyPair::generate(); - let alice_state: RatchetState = - RatchetState::init_sender(shared_secret, bob_keypair.public().clone()); - let bob_state: RatchetState = + let alice: RatchetState = + RatchetState::init_sender(shared_secret, *bob_keypair.public()); + let bob: RatchetState = RatchetState::init_receiver(shared_secret, bob_keypair); // Alice sends - let mut alice_session = RatchetSession::create(alice_storage, "conv", alice_state).unwrap(); - let (ct, header) = alice_session.encrypt_message(b"Hello Bob").unwrap(); + let (ct, header) = { + let mut session = RatchetSession::create(&mut storage, "alice", alice).unwrap(); + session.encrypt_message(b"Hello Bob").unwrap() + }; // Bob receives - let mut bob_session = RatchetSession::create(bob_storage, "conv", bob_state).unwrap(); - let plaintext = bob_session.decrypt_message(&ct, header).unwrap(); + let plaintext = { + let mut session = RatchetSession::create(&mut storage, "bob", bob).unwrap(); + session.decrypt_message(&ct, header).unwrap() + }; assert_eq!(plaintext, b"Hello Bob"); // Bob replies - let (ct2, header2) = bob_session.encrypt_message(b"Hi Alice").unwrap(); + let (ct2, header2) = { + let mut session: RatchetSession = + RatchetSession::open(&mut storage, "bob").unwrap(); + session.encrypt_message(b"Hi Alice").unwrap() + }; // Alice receives - let plaintext2 = alice_session.decrypt_message(&ct2, header2).unwrap(); + let plaintext2 = { + let mut session: RatchetSession = + RatchetSession::open(&mut storage, "alice").unwrap(); + session.decrypt_message(&ct2, header2).unwrap() + }; assert_eq!(plaintext2, b"Hi Alice"); } #[test] fn test_session_open_or_create() { - let storage = create_test_storage(); + let mut storage = create_test_storage(); let shared_secret = [0x42; 32]; let bob_keypair = InstallationKeyPair::generate(); let bob_pub = *bob_keypair.public(); // First call creates - let session: RatchetSession = - RatchetSession::create_sender_session(storage, "conv1", shared_secret, bob_pub.clone()) - .unwrap(); - assert_eq!(session.state().msg_send, 0); - let storage = session.into_storage(); - - // Second call opens existing and encrypts - let mut session: RatchetSession = - RatchetSession::open(storage, "conv1").unwrap(); - session.encrypt_message(b"test").unwrap(); - let storage = session.into_storage(); + { + let session: RatchetSession = RatchetSession::create_sender_session( + &mut storage, + "conv1", + shared_secret, + bob_pub, + ) + .unwrap(); + assert_eq!(session.state().msg_send, 0); + } + + // Second call opens existing + { + let mut session: RatchetSession = + RatchetSession::open(&mut storage, "conv1").unwrap(); + session.encrypt_message(b"test").unwrap(); + } // Verify persistence - let session: RatchetSession = - RatchetSession::open(storage, "conv1").unwrap(); - assert_eq!(session.state().msg_send, 1); + { + let session: RatchetSession = + RatchetSession::open(&mut storage, "conv1").unwrap(); + assert_eq!(session.state().msg_send, 1); + } } #[test] fn test_create_sender_session_fails_when_conversation_exists() { - let storage = create_test_storage(); + let mut storage = create_test_storage(); let shared_secret = [0x42; 32]; let bob_keypair = InstallationKeyPair::generate(); let bob_pub = *bob_keypair.public(); // First creation succeeds - let session: RatchetSession = - RatchetSession::create_sender_session(storage, "conv1", shared_secret, bob_pub.clone()) - .unwrap(); - let storage = session.into_storage(); + { + let _session: RatchetSession = RatchetSession::create_sender_session( + &mut storage, + "conv1", + shared_secret, + bob_pub, + ) + .unwrap(); + } // Second creation should fail with ConversationAlreadyExists - let result: Result, _> = - RatchetSession::create_sender_session(storage, "conv1", shared_secret, bob_pub.clone()); - - assert!(matches!(result, Err(SessionError::ConvAlreadyExists(_)))); + { + let result: Result, _> = + RatchetSession::create_sender_session( + &mut storage, + "conv1", + shared_secret, + bob_pub, + ); + + assert!(matches!(result, Err(SessionError::ConvAlreadyExists(_)))); + } } #[test] fn test_create_receiver_session_fails_when_conversation_exists() { - let storage = create_test_storage(); + let mut storage = create_test_storage(); let shared_secret = [0x42; 32]; let bob_keypair = InstallationKeyPair::generate(); // First creation succeeds - let session: RatchetSession = - RatchetSession::create_receiver_session(storage, "conv1", shared_secret, bob_keypair) - .unwrap(); - let storage = session.into_storage(); - - // Second creation should fail with ConversationAlreadyExists - let another_keypair = InstallationKeyPair::generate(); - let result: Result, _> = - RatchetSession::create_receiver_session( - storage, + { + let _session: RatchetSession = RatchetSession::create_receiver_session( + &mut storage, "conv1", shared_secret, - another_keypair, - ); + bob_keypair, + ) + .unwrap(); + } - assert!(matches!(result, Err(SessionError::ConvAlreadyExists(_)))); + // Second creation should fail with ConversationAlreadyExists + { + let another_keypair = InstallationKeyPair::generate(); + let result: Result, _> = + RatchetSession::create_receiver_session( + &mut storage, + "conv1", + shared_secret, + another_keypair, + ); + + assert!(matches!(result, Err(SessionError::ConvAlreadyExists(_)))); + } } } From 7019b04ccb2405f8999e061c79592f0b2123d9d9 Mon Sep 17 00:00:00 2001 From: kaichaosun Date: Fri, 27 Feb 2026 14:57:25 +0800 Subject: [PATCH 06/12] fix: clean --- conversations/src/context.rs | 1 + conversations/src/errors.rs | 2 -- .../storage/migrations/001_initial_schema.sql | 18 ------------------ conversations/src/storage/types.rs | 9 --------- storage/src/sqlite.rs | 16 +--------------- 5 files changed, 2 insertions(+), 44 deletions(-) diff --git a/conversations/src/context.rs b/conversations/src/context.rs index 87a68fe..fe410d0 100644 --- a/conversations/src/context.rs +++ b/conversations/src/context.rs @@ -31,6 +31,7 @@ pub struct Context { _identity: Rc, store: ConversationStore, inbox: Inbox, + #[allow(dead_code)] // Will be used for conversation persistence storage: ChatStorage, } diff --git a/conversations/src/errors.rs b/conversations/src/errors.rs index 1df0e68..d551960 100644 --- a/conversations/src/errors.rs +++ b/conversations/src/errors.rs @@ -20,8 +20,6 @@ pub enum ChatError { BadParsing(&'static str), #[error("convo with id: {0} was not found")] NoConvo(String), - #[error("session error: {0}")] - Session(#[from] double_ratchets::SessionError), } #[derive(Error, Debug)] diff --git a/conversations/src/storage/migrations/001_initial_schema.sql b/conversations/src/storage/migrations/001_initial_schema.sql index 70b5359..5a97bfe 100644 --- a/conversations/src/storage/migrations/001_initial_schema.sql +++ b/conversations/src/storage/migrations/001_initial_schema.sql @@ -7,21 +7,3 @@ CREATE TABLE IF NOT EXISTS identity ( name TEXT NOT NULL, secret_key BLOB NOT NULL ); - --- Inbox ephemeral keys for handshakes -CREATE TABLE IF NOT EXISTS inbox_keys ( - public_key_hex TEXT PRIMARY KEY, - secret_key BLOB NOT NULL, - created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) -); - --- Chat metadata -CREATE TABLE IF NOT EXISTS chats ( - chat_id TEXT PRIMARY KEY, - chat_type TEXT NOT NULL, - remote_public_key BLOB, - remote_address TEXT NOT NULL, - created_at INTEGER NOT NULL -); - -CREATE INDEX IF NOT EXISTS idx_chats_type ON chats(chat_type); diff --git a/conversations/src/storage/types.rs b/conversations/src/storage/types.rs index d82a421..d4d48d9 100644 --- a/conversations/src/storage/types.rs +++ b/conversations/src/storage/types.rs @@ -12,15 +12,6 @@ pub struct IdentityRecord { pub secret_key: [u8; 32], } -impl From<&Identity> for IdentityRecord { - fn from(identity: &Identity) -> Self { - Self { - name: identity.get_name().to_string(), - secret_key: identity.secret().DANGER_to_bytes(), - } - } -} - impl From for Identity { fn from(record: IdentityRecord) -> Self { let secret = PrivateKey::from(record.secret_key); diff --git a/storage/src/sqlite.rs b/storage/src/sqlite.rs index 8449532..4d42e9d 100644 --- a/storage/src/sqlite.rs +++ b/storage/src/sqlite.rs @@ -8,11 +8,8 @@ use crate::StorageError; /// Configuration for SQLite storage. #[derive(Debug, Clone)] pub enum StorageConfig { - /// In-memory database (isolated, for simple testing). + /// In-memory database (for testing). InMemory, - /// Shared in-memory database with a name (multiple connections share data). - /// Use this when you need multiple storage instances to share the same in-memory DB. - SharedInMemory(String), /// File-based SQLite database. File(String), /// SQLCipher encrypted database. @@ -32,17 +29,6 @@ impl SqliteDb { pub fn new(config: StorageConfig) -> Result { let conn = match config { StorageConfig::InMemory => Connection::open_in_memory()?, - StorageConfig::SharedInMemory(ref name) => { - // Use URI mode to create a shared in-memory database - // Multiple connections with the same name share the same data - let uri = format!("file:{}?mode=memory&cache=shared", name); - Connection::open_with_flags( - &uri, - rusqlite::OpenFlags::SQLITE_OPEN_URI - | rusqlite::OpenFlags::SQLITE_OPEN_READ_WRITE - | rusqlite::OpenFlags::SQLITE_OPEN_CREATE, - )? - } StorageConfig::File(ref path) => Connection::open(path)?, StorageConfig::Encrypted { ref path, ref key } => { let conn = Connection::open(path)?; From 3673d730f3881941afe356e209d7b16f318ac295 Mon Sep 17 00:00:00 2001 From: kaichaosun Date: Fri, 27 Feb 2026 15:28:45 +0800 Subject: [PATCH 07/12] refactor: use result wrapper for ffi --- conversations/src/api.rs | 17 +++++++++++++---- conversations/src/context.rs | 3 --- conversations/src/ffi/mod.rs | 1 + conversations/src/ffi/utils.rs | 13 +++++++++++++ conversations/src/lib.rs | 1 + 5 files changed, 28 insertions(+), 7 deletions(-) create mode 100644 conversations/src/ffi/mod.rs create mode 100644 conversations/src/ffi/utils.rs diff --git a/conversations/src/api.rs b/conversations/src/api.rs index 8ba81b9..6493980 100644 --- a/conversations/src/api.rs +++ b/conversations/src/api.rs @@ -18,6 +18,7 @@ use storage::StorageConfig; use crate::{ context::{Context, Introduction}, errors::ChatError, + ffi::utils::CResult, types::ContentData, }; @@ -65,15 +66,23 @@ pub fn create_context(name: repr_c::String) -> repr_c::Box { /// - db_path: Path to the SQLite database file /// /// # Returns -/// Opaque handle to the context. Must be freed with destroy_context() +/// CResult with context handle on success, or error string on failure #[ffi_export] pub fn create_context_with_storage( name: repr_c::String, db_path: repr_c::String, -) -> repr_c::Box { +) -> CResult, repr_c::String> { let config = StorageConfig::File(db_path.to_string()); - let ctx = Context::open(&*name, config).expect("failed to open context with storage"); - Box::new(ContextHandle(ctx)).into() + 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. diff --git a/conversations/src/context.rs b/conversations/src/context.rs index fe410d0..f112053 100644 --- a/conversations/src/context.rs +++ b/conversations/src/context.rs @@ -18,9 +18,6 @@ pub use crate::inbox::Introduction; /// Error type for Context operations. #[derive(Debug, thiserror::Error)] pub enum ContextError { - #[error("chat error: {0}")] - Chat(#[from] ChatError), - #[error("storage error: {0}")] Storage(#[from] StorageError), } 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..f50033f --- /dev/null +++ b/conversations/src/ffi/utils.rs @@ -0,0 +1,13 @@ +use safer_ffi::prelude::*; + +#[derive_ReprC] +#[repr(C)] +pub struct CResult { + pub ok: Option, + pub err: Option, +} + +#[ffi_export] +pub fn ffi_c_string_free(s: repr_c::String) { + drop(s); +} diff --git a/conversations/src/lib.rs b/conversations/src/lib.rs index e13c3fa..5490629 100644 --- a/conversations/src/lib.rs +++ b/conversations/src/lib.rs @@ -3,6 +3,7 @@ mod context; mod conversation; mod crypto; mod errors; +mod ffi; mod identity; mod inbox; mod proto; From fdacfae108092ae23d6f61b0bf529aa364c188ed Mon Sep 17 00:00:00 2001 From: kaichaosun Date: Fri, 27 Feb 2026 15:31:38 +0800 Subject: [PATCH 08/12] refactor: uniform storage error into chat error --- conversations/src/api.rs | 4 +++- conversations/src/context.rs | 11 ++--------- conversations/src/errors.rs | 4 ++++ conversations/src/ffi/utils.rs | 2 +- conversations/src/storage/mod.rs | 1 - 5 files changed, 10 insertions(+), 12 deletions(-) diff --git a/conversations/src/api.rs b/conversations/src/api.rs index 6493980..2fa1625 100644 --- a/conversations/src/api.rs +++ b/conversations/src/api.rs @@ -66,7 +66,9 @@ pub fn create_context(name: repr_c::String) -> repr_c::Box { /// - db_path: Path to the SQLite database file /// /// # Returns -/// CResult with context handle on success, or error string on failure +/// 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, diff --git a/conversations/src/context.rs b/conversations/src/context.rs index f112053..bec37a5 100644 --- a/conversations/src/context.rs +++ b/conversations/src/context.rs @@ -8,20 +8,13 @@ use crate::{ identity::Identity, inbox::Inbox, proto::{EncryptedPayload, EnvelopeV1, Message}, - storage::{ChatStorage, StorageError}, + storage::ChatStorage, types::{AddressedEnvelope, ContentData}, }; pub use crate::conversation::ConversationIdOwned; pub use crate::inbox::Introduction; -/// Error type for Context operations. -#[derive(Debug, thiserror::Error)] -pub enum ContextError { - #[error("storage error: {0}")] - Storage(#[from] StorageError), -} - // This is the main entry point to the conversations api. // Ctx manages lifetimes of objects to process and generate payloads. pub struct Context { @@ -37,7 +30,7 @@ impl Context { /// /// 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 { + pub fn open(name: impl Into, config: StorageConfig) -> Result { let mut storage = ChatStorage::new(config)?; let name = name.into(); 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/utils.rs b/conversations/src/ffi/utils.rs index f50033f..0df0b3b 100644 --- a/conversations/src/ffi/utils.rs +++ b/conversations/src/ffi/utils.rs @@ -8,6 +8,6 @@ pub struct CResult { } #[ffi_export] -pub fn ffi_c_string_free(s: repr_c::String) { +pub fn destroy_string(s: repr_c::String) { drop(s); } diff --git a/conversations/src/storage/mod.rs b/conversations/src/storage/mod.rs index 7a32751..9364aeb 100644 --- a/conversations/src/storage/mod.rs +++ b/conversations/src/storage/mod.rs @@ -5,4 +5,3 @@ mod migrations; pub(crate) mod types; pub(crate) use db::ChatStorage; -pub(crate) use storage::StorageError; From 0e62c44b7ef507985c97d97bfcbcf78cbec7c9bb Mon Sep 17 00:00:00 2001 From: kaichaosun Date: Fri, 27 Feb 2026 15:48:44 +0800 Subject: [PATCH 09/12] fix: zeroize identity record --- Cargo.lock | 1 + conversations/Cargo.toml | 1 + conversations/src/storage/types.rs | 42 ++++++++++++++++++++++++++++-- 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bffaee7..4cd52f0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -505,6 +505,7 @@ dependencies = [ "tempfile", "thiserror", "x25519-dalek", + "zeroize", ] [[package]] diff --git a/conversations/Cargo.toml b/conversations/Cargo.toml index b77d9f6..4ea9408 100644 --- a/conversations/Cargo.toml +++ b/conversations/Cargo.toml @@ -19,6 +19,7 @@ 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/storage/types.rs b/conversations/src/storage/types.rs index d4d48d9..8767324 100644 --- a/conversations/src/storage/types.rs +++ b/conversations/src/storage/types.rs @@ -1,10 +1,13 @@ //! Storage record types for serialization/deserialization. +use zeroize::{Zeroize, ZeroizeOnDrop}; + use crate::crypto::PrivateKey; use crate::identity::Identity; /// Record for storing identity (secret key). -#[derive(Debug)] +/// Implements ZeroizeOnDrop to securely clear secret key from memory. +#[derive(Debug, Zeroize, ZeroizeOnDrop)] pub struct IdentityRecord { /// The identity name. pub name: String, @@ -14,7 +17,42 @@ pub struct IdentityRecord { impl From for Identity { fn from(record: IdentityRecord) -> Self { + let name = record.name.clone(); let secret = PrivateKey::from(record.secret_key); - Identity::from_secret(record.name, secret) + 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"); } } From c3e5f361bbdccae88234bb2470c050e92725b867 Mon Sep 17 00:00:00 2001 From: kaichaosun Date: Mon, 2 Mar 2026 11:38:44 +0800 Subject: [PATCH 10/12] fix: zeroize for secret keys in db operations --- conversations/src/api.rs | 7 ++++++- conversations/src/storage/db.rs | 35 +++++++++++++++++++++++---------- 2 files changed, 31 insertions(+), 11 deletions(-) diff --git a/conversations/src/api.rs b/conversations/src/api.rs index 2fa1625..75319f9 100644 --- a/conversations/src/api.rs +++ b/conversations/src/api.rs @@ -64,6 +64,7 @@ pub fn create_context(name: repr_c::String) -> repr_c::Box { /// # 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. @@ -73,8 +74,12 @@ pub fn create_context(name: repr_c::String) -> repr_c::Box { 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::File(db_path.to_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()), diff --git a/conversations/src/storage/db.rs b/conversations/src/storage/db.rs index 86f40fd..65750e9 100644 --- a/conversations/src/storage/db.rs +++ b/conversations/src/storage/db.rs @@ -1,6 +1,7 @@ //! Chat-specific storage implementation. use storage::{RusqliteError, SqliteDb, StorageConfig, StorageError, params}; +use zeroize::Zeroize; use super::migrations; use super::types::IdentityRecord; @@ -32,18 +33,24 @@ impl ChatStorage { // ==================== 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> { - self.db.connection().execute( + 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(), - identity.secret().DANGER_to_bytes().as_slice() - ], - )?; + 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 @@ -57,10 +64,18 @@ impl ChatStorage { }); match result { - Ok((name, secret_key)) => { - let bytes: [u8; 32] = secret_key - .try_into() - .map_err(|_| StorageError::InvalidData("Invalid secret key length".into()))?; + 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, From 030ab475bed29ebdf44c49276755a7b6063f5dde Mon Sep 17 00:00:00 2001 From: kaichaosun Date: Mon, 2 Mar 2026 11:42:21 +0800 Subject: [PATCH 11/12] fix: transactional sql migration --- conversations/src/storage/db.rs | 4 ++-- conversations/src/storage/migrations.rs | 14 ++++++++------ storage/src/sqlite.rs | 7 +++++++ 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/conversations/src/storage/db.rs b/conversations/src/storage/db.rs index 65750e9..c855416 100644 --- a/conversations/src/storage/db.rs +++ b/conversations/src/storage/db.rs @@ -25,8 +25,8 @@ impl ChatStorage { } /// Applies all migrations and returns the storage instance. - fn run_migrations(db: SqliteDb) -> Result { - migrations::apply_migrations(db.connection())?; + fn run_migrations(mut db: SqliteDb) -> Result { + migrations::apply_migrations(db.connection_mut())?; Ok(Self { db }) } diff --git a/conversations/src/storage/migrations.rs b/conversations/src/storage/migrations.rs index 41b3cb4..014bb96 100644 --- a/conversations/src/storage/migrations.rs +++ b/conversations/src/storage/migrations.rs @@ -1,6 +1,7 @@ //! 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}; @@ -13,8 +14,9 @@ pub fn get_migrations() -> Vec<(&'static str, &'static str)> { } /// Applies all migrations to the database. +/// /// Uses a simple version tracking table to avoid re-running migrations. -pub fn apply_migrations(conn: &Connection) -> Result<(), StorageError> { +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 ( @@ -32,11 +34,11 @@ pub fn apply_migrations(conn: &Connection) -> Result<(), StorageError> { )?; if !already_applied { - // Apply migration - conn.execute_batch(sql)?; - - // Record migration - conn.execute("INSERT INTO _migrations (name) VALUES (?1)", [name])?; + // 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()?; } } 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()?) From 10a403e6faa04c8bc041379cbff240ec7cb6dfa0 Mon Sep 17 00:00:00 2001 From: kaichaosun Date: Mon, 2 Mar 2026 12:06:45 +0800 Subject: [PATCH 12/12] fix: remove destroy_string --- conversations/src/ffi/utils.rs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/conversations/src/ffi/utils.rs b/conversations/src/ffi/utils.rs index 0df0b3b..7989631 100644 --- a/conversations/src/ffi/utils.rs +++ b/conversations/src/ffi/utils.rs @@ -6,8 +6,3 @@ pub struct CResult { pub ok: Option, pub err: Option, } - -#[ffi_export] -pub fn destroy_string(s: repr_c::String) { - drop(s); -}