//! Multi-conversation state backed by SQLite (SQLCipher-encrypted when a //! password is provided). //! //! Each conversation (DM or group) has its own MLS group blob, keystore blob, //! member list, and message history. use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; use anyhow::Context; use argon2::{Algorithm, Argon2, Params, Version}; use rand::RngCore; use rusqlite::{params, Connection, OptionalExtension}; use zeroize::Zeroizing; // ── Types ──────────────────────────────────────────────────────────────────── /// 16-byte conversation identifier. /// - DMs: the channel_id returned by `createChannel` (server-assigned UUID). /// - Groups: SHA-256(group_name)[..16]. #[derive(Clone, Debug, PartialEq, Eq, Hash)] pub struct ConversationId(pub [u8; 16]); impl ConversationId { pub fn from_slice(s: &[u8]) -> Option { if s.len() == 16 { let mut buf = [0u8; 16]; buf.copy_from_slice(s); Some(Self(buf)) } else { None } } /// Derive a conversation ID from a group name via SHA-256 truncation. pub fn from_group_name(name: &str) -> Self { use sha2::{Sha256, Digest}; let hash = Sha256::digest(name.as_bytes()); let mut buf = [0u8; 16]; buf.copy_from_slice(&hash[..16]); Self(buf) } pub fn hex(&self) -> String { hex::encode(self.0) } } #[derive(Clone, Debug, PartialEq, Eq)] pub enum ConversationKind { /// 1:1 DM channel with a specific peer. Dm { peer_key: Vec, peer_username: Option, }, /// Named group with N members. Group { name: String }, } #[derive(Clone, Debug)] pub struct Conversation { pub id: ConversationId, pub kind: ConversationKind, pub display_name: String, /// Serialized MLS group (bincode). pub mls_group_blob: Option>, /// Serialized keystore (bincode HashMap). pub keystore_blob: Option>, /// Member identity keys (bincode Vec>). pub member_keys: Vec>, pub unread_count: u32, pub last_activity_ms: u64, pub created_at_ms: u64, /// Whether this conversation uses hybrid (X25519 + ML-KEM-768) MLS keys. pub is_hybrid: bool, /// Highest server-side delivery sequence number seen. pub last_seen_seq: u64, } #[derive(Clone, Debug)] pub struct StoredMessage { pub conversation_id: ConversationId, pub message_id: Option<[u8; 16]>, pub sender_key: Vec, pub sender_name: Option, pub body: String, pub msg_type: String, pub ref_msg_id: Option<[u8; 16]>, pub timestamp_ms: u64, pub is_outgoing: bool, } // ── Key derivation (Argon2id, matching state.rs parameters) ───────────────── const ARGON2_M_COST: u32 = 19 * 1024; const ARGON2_T_COST: u32 = 2; const ARGON2_P_COST: u32 = 1; const SALT_LEN: usize = 16; /// Derive a 32-byte SQLCipher key from the user password and a random salt. fn derive_convdb_key(password: &str, salt: &[u8]) -> anyhow::Result> { let params = Params::new(ARGON2_M_COST, ARGON2_T_COST, ARGON2_P_COST, Some(32)) .map_err(|e| anyhow::anyhow!("argon2 params: {e}"))?; let argon2 = Argon2::new(Algorithm::Argon2id, Version::default(), params); let mut key = Zeroizing::new([0u8; 32]); argon2 .hash_password_into(password.as_bytes(), salt, &mut *key) .map_err(|e| anyhow::anyhow!("convdb key derivation: {e}"))?; Ok(key) } /// Read or create a 16-byte random salt at `salt_path` (mode 0o600). fn get_or_create_salt(salt_path: &Path) -> anyhow::Result> { if salt_path.exists() { let bytes = std::fs::read(salt_path).context("read convdb salt")?; anyhow::ensure!(bytes.len() == SALT_LEN, "invalid convdb salt length"); return Ok(bytes); } let mut salt = vec![0u8; SALT_LEN]; rand::rngs::OsRng.fill_bytes(&mut salt); std::fs::write(salt_path, &salt).context("write convdb salt")?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; std::fs::set_permissions(salt_path, std::fs::Permissions::from_mode(0o600)).ok(); } Ok(salt) } // ── ConversationStore ──────────────────────────────────────────────────────── pub struct ConversationStore { conn: Connection, } impl ConversationStore { /// Open or create the conversation database at `db_path`. /// If `password` is `Some`, the database is encrypted with SQLCipher using /// an Argon2id-derived key. Existing unencrypted databases are migrated /// transparently. pub fn open(db_path: &Path, password: Option<&str>) -> anyhow::Result { if let Some(parent) = db_path.parent() { std::fs::create_dir_all(parent).ok(); } match password { Some(pw) => Self::open_encrypted(db_path, pw), None => Self::open_plain(db_path), } } fn open_plain(db_path: &Path) -> anyhow::Result { let conn = Connection::open(db_path).context("open conversation db")?; conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;") .context("set pragmas")?; Self::migrate(&conn)?; Ok(Self { conn }) } fn open_encrypted(db_path: &Path, password: &str) -> anyhow::Result { let salt_path = PathBuf::from(format!("{}-salt", db_path.display())); let already_encrypted = salt_path.exists(); // Migrate an existing unencrypted database before opening with encryption. if db_path.exists() && !already_encrypted { Self::migrate_plain_to_encrypted(db_path, &salt_path, password)?; // After migration, salt file exists and DB is encrypted — fall through. } let salt = get_or_create_salt(&salt_path)?; let key = derive_convdb_key(password, &salt)?; let hex_key = hex::encode(&*key); let conn = Connection::open(db_path).context("open conversation db")?; conn.pragma_update(None, "key", format!("x'{hex_key}'")) .context("set SQLCipher key")?; conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;") .context("set pragmas")?; Self::migrate(&conn)?; Ok(Self { conn }) } /// Migrate an unencrypted `.convdb` to an encrypted one in-place. fn migrate_plain_to_encrypted( db_path: &Path, salt_path: &Path, password: &str, ) -> anyhow::Result<()> { let salt = get_or_create_salt(salt_path)?; let key = derive_convdb_key(password, &salt)?; let hex_key = hex::encode(&*key); let enc_path = db_path.with_extension("convdb-enc"); // Open the existing plaintext database. let plain = Connection::open(db_path).context("open plain convdb for migration")?; plain.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;").ok(); // Attach a new encrypted database and export into it. plain .execute_batch(&format!( "ATTACH DATABASE '{}' AS encrypted KEY \"x'{hex_key}'\";", enc_path.display() )) .context("attach encrypted db for migration")?; plain .execute_batch("SELECT sqlcipher_export('encrypted');") .context("sqlcipher_export to encrypted db")?; plain .execute_batch("DETACH DATABASE encrypted;") .context("detach encrypted db")?; drop(plain); // Swap files: encrypted → original. std::fs::rename(&enc_path, db_path).context("replace convdb with encrypted version")?; // Clean up WAL/SHM left from the plaintext open. let wal = PathBuf::from(format!("{}-wal", db_path.display())); let shm = PathBuf::from(format!("{}-shm", db_path.display())); std::fs::remove_file(&wal).ok(); std::fs::remove_file(&shm).ok(); tracing::info!("migrated conversation database to encrypted storage"); Ok(()) } fn migrate(conn: &Connection) -> anyhow::Result<()> { conn.execute_batch( "CREATE TABLE IF NOT EXISTS conversations ( id BLOB PRIMARY KEY, kind TEXT NOT NULL, display_name TEXT NOT NULL, peer_key BLOB, peer_username TEXT, group_name TEXT, mls_group_blob BLOB, keystore_blob BLOB, member_keys BLOB, unread_count INTEGER NOT NULL DEFAULT 0, last_activity_ms INTEGER NOT NULL DEFAULT 0, created_at_ms INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, conversation_id BLOB NOT NULL REFERENCES conversations(id), message_id BLOB, sender_key BLOB NOT NULL, sender_name TEXT, body TEXT NOT NULL, msg_type TEXT NOT NULL, ref_msg_id BLOB, timestamp_ms INTEGER NOT NULL, is_outgoing INTEGER NOT NULL DEFAULT 0 ); CREATE INDEX IF NOT EXISTS idx_messages_conv ON messages(conversation_id, timestamp_ms); CREATE TABLE IF NOT EXISTS outbox ( id INTEGER PRIMARY KEY AUTOINCREMENT, conversation_id BLOB NOT NULL, recipient_key BLOB NOT NULL, payload BLOB NOT NULL, created_at_ms INTEGER NOT NULL, retry_count INTEGER NOT NULL DEFAULT 0, status TEXT NOT NULL DEFAULT 'pending' ); CREATE INDEX IF NOT EXISTS idx_outbox_status ON outbox(status, created_at_ms);", ) .context("migrate conversation db")?; // Additive migrations for new columns (safe to re-run; errors ignored if column already exists). conn.execute_batch("ALTER TABLE conversations ADD COLUMN is_hybrid INTEGER NOT NULL DEFAULT 0;").ok(); conn.execute_batch("ALTER TABLE conversations ADD COLUMN last_seen_seq INTEGER NOT NULL DEFAULT 0;").ok(); Ok(()) } // ── Conversation CRUD ──────────────────────────────────────────────── pub fn save_conversation(&self, conv: &Conversation) -> anyhow::Result<()> { let (kind_str, peer_key, peer_username, group_name) = match &conv.kind { ConversationKind::Dm { peer_key, peer_username, } => ("dm", Some(peer_key.as_slice()), peer_username.as_deref(), None), ConversationKind::Group { name } => ("group", None, None, Some(name.as_str())), }; let member_keys_blob = bincode::serialize(&conv.member_keys) .context("serialize member_keys")?; self.conn.execute( "INSERT INTO conversations (id, kind, display_name, peer_key, peer_username, group_name, mls_group_blob, keystore_blob, member_keys, unread_count, last_activity_ms, created_at_ms, is_hybrid, last_seen_seq) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14) ON CONFLICT(id) DO UPDATE SET display_name = excluded.display_name, mls_group_blob = excluded.mls_group_blob, keystore_blob = excluded.keystore_blob, member_keys = excluded.member_keys, unread_count = excluded.unread_count, last_activity_ms = excluded.last_activity_ms, is_hybrid = excluded.is_hybrid, last_seen_seq = excluded.last_seen_seq", params![ conv.id.0.as_slice(), kind_str, conv.display_name, peer_key, peer_username, group_name, conv.mls_group_blob, conv.keystore_blob, member_keys_blob, conv.unread_count, conv.last_activity_ms, conv.created_at_ms, conv.is_hybrid as i32, conv.last_seen_seq as i64, ], )?; Ok(()) } pub fn load_conversation(&self, id: &ConversationId) -> anyhow::Result> { self.conn .query_row( "SELECT kind, display_name, peer_key, peer_username, group_name, mls_group_blob, keystore_blob, member_keys, unread_count, last_activity_ms, created_at_ms, is_hybrid, last_seen_seq FROM conversations WHERE id = ?1", params![id.0.as_slice()], |row| { let kind_str: String = row.get(0)?; let display_name: String = row.get(1)?; let peer_key: Option> = row.get(2)?; let peer_username: Option = row.get(3)?; let group_name: Option = row.get(4)?; let mls_group_blob: Option> = row.get(5)?; let keystore_blob: Option> = row.get(6)?; let member_keys_blob: Option> = row.get(7)?; let unread_count: u32 = row.get(8)?; let last_activity_ms: u64 = row.get(9)?; let created_at_ms: u64 = row.get(10)?; let is_hybrid_int: i32 = row.get(11)?; let last_seen_seq: i64 = row.get(12)?; let kind = if kind_str == "dm" { ConversationKind::Dm { peer_key: peer_key.unwrap_or_default(), peer_username, } } else { ConversationKind::Group { name: group_name.unwrap_or_default(), } }; let member_keys: Vec> = member_keys_blob .and_then(|b| bincode::deserialize(&b).ok()) .unwrap_or_default(); Ok(Conversation { id: id.clone(), kind, display_name, mls_group_blob, keystore_blob, member_keys, unread_count, last_activity_ms, created_at_ms, is_hybrid: is_hybrid_int != 0, last_seen_seq: last_seen_seq as u64, }) }, ) .optional() .context("load conversation") } pub fn list_conversations(&self) -> anyhow::Result> { let mut stmt = self.conn.prepare( "SELECT id, kind, display_name, peer_key, peer_username, group_name, mls_group_blob, keystore_blob, member_keys, unread_count, last_activity_ms, created_at_ms, is_hybrid, last_seen_seq FROM conversations ORDER BY last_activity_ms DESC", )?; let rows = stmt.query_map([], |row| { let id_blob: Vec = row.get(0)?; let kind_str: String = row.get(1)?; let display_name: String = row.get(2)?; let peer_key: Option> = row.get(3)?; let peer_username: Option = row.get(4)?; let group_name: Option = row.get(5)?; let mls_group_blob: Option> = row.get(6)?; let keystore_blob: Option> = row.get(7)?; let member_keys_blob: Option> = row.get(8)?; let unread_count: u32 = row.get(9)?; let last_activity_ms: u64 = row.get(10)?; let created_at_ms: u64 = row.get(11)?; let is_hybrid_int: i32 = row.get(12)?; let last_seen_seq: i64 = row.get(13)?; let id = ConversationId::from_slice(&id_blob).unwrap_or(ConversationId([0; 16])); let kind = if kind_str == "dm" { ConversationKind::Dm { peer_key: peer_key.unwrap_or_default(), peer_username, } } else { ConversationKind::Group { name: group_name.unwrap_or_default(), } }; let member_keys: Vec> = member_keys_blob .and_then(|b| bincode::deserialize(&b).ok()) .unwrap_or_default(); Ok(Conversation { id, kind, display_name, mls_group_blob, keystore_blob, member_keys, unread_count, last_activity_ms, created_at_ms, is_hybrid: is_hybrid_int != 0, last_seen_seq: last_seen_seq as u64, }) })?; let mut convs = Vec::new(); for row in rows { convs.push(row?); } Ok(convs) } /// Find a DM conversation by the peer's identity key. pub fn find_dm_by_peer(&self, peer_key: &[u8]) -> anyhow::Result> { let id_blob: Option> = self .conn .query_row( "SELECT id FROM conversations WHERE kind = 'dm' AND peer_key = ?1", params![peer_key], |row| row.get(0), ) .optional()?; match id_blob { Some(blob) => { let id = ConversationId::from_slice(&blob) .context("invalid conversation id in db")?; self.load_conversation(&id) } None => Ok(None), } } /// Find a group conversation by name. pub fn find_group_by_name(&self, name: &str) -> anyhow::Result> { let id_blob: Option> = self .conn .query_row( "SELECT id FROM conversations WHERE kind = 'group' AND group_name = ?1", params![name], |row| row.get(0), ) .optional()?; match id_blob { Some(blob) => { let id = ConversationId::from_slice(&blob) .context("invalid conversation id in db")?; self.load_conversation(&id) } None => Ok(None), } } pub fn increment_unread(&self, id: &ConversationId) -> anyhow::Result<()> { self.conn.execute( "UPDATE conversations SET unread_count = unread_count + 1 WHERE id = ?1", params![id.0.as_slice()], )?; Ok(()) } pub fn reset_unread(&self, id: &ConversationId) -> anyhow::Result<()> { self.conn.execute( "UPDATE conversations SET unread_count = 0 WHERE id = ?1", params![id.0.as_slice()], )?; Ok(()) } pub fn update_activity(&self, id: &ConversationId, ts_ms: u64) -> anyhow::Result<()> { self.conn.execute( "UPDATE conversations SET last_activity_ms = ?2 WHERE id = ?1 AND last_activity_ms < ?2", params![id.0.as_slice(), ts_ms], )?; Ok(()) } // ── Message CRUD ───────────────────────────────────────────────────── pub fn save_message(&self, msg: &StoredMessage) -> anyhow::Result<()> { self.conn.execute( "INSERT INTO messages (conversation_id, message_id, sender_key, sender_name, body, msg_type, ref_msg_id, timestamp_ms, is_outgoing) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", params![ msg.conversation_id.0.as_slice(), msg.message_id.as_ref().map(|id| id.as_slice()), msg.sender_key, msg.sender_name, msg.body, msg.msg_type, msg.ref_msg_id.as_ref().map(|id| id.as_slice()), msg.timestamp_ms, msg.is_outgoing as i32, ], )?; Ok(()) } pub fn load_recent_messages( &self, conv_id: &ConversationId, limit: usize, ) -> anyhow::Result> { let mut stmt = self.conn.prepare( "SELECT message_id, sender_key, sender_name, body, msg_type, ref_msg_id, timestamp_ms, is_outgoing FROM messages WHERE conversation_id = ?1 ORDER BY timestamp_ms DESC LIMIT ?2", )?; let rows = stmt.query_map(params![conv_id.0.as_slice(), limit as u32], |row| { let message_id: Option> = row.get(0)?; let sender_key: Vec = row.get(1)?; let sender_name: Option = row.get(2)?; let body: String = row.get(3)?; let msg_type: String = row.get(4)?; let ref_msg_id: Option> = row.get(5)?; let timestamp_ms: u64 = row.get(6)?; let is_outgoing: i32 = row.get(7)?; fn to_16(v: &[u8]) -> Option<[u8; 16]> { if v.len() == 16 { let mut buf = [0u8; 16]; buf.copy_from_slice(v); Some(buf) } else { None } } Ok(StoredMessage { conversation_id: conv_id.clone(), message_id: message_id.as_deref().and_then(to_16), sender_key, sender_name, body, msg_type, ref_msg_id: ref_msg_id.as_deref().and_then(to_16), timestamp_ms, is_outgoing: is_outgoing != 0, }) })?; let mut msgs = Vec::new(); for row in rows { msgs.push(row?); } // Reverse so oldest first msgs.reverse(); Ok(msgs) } /// Save a message, deduplicating by message_id within the same conversation. /// Returns `true` if the message was saved (new), `false` if it was a duplicate. pub fn save_message_dedup(&self, msg: &StoredMessage) -> anyhow::Result { if let Some(ref mid) = msg.message_id { let exists: bool = self.conn.query_row( "SELECT EXISTS(SELECT 1 FROM messages WHERE message_id = ?1 AND conversation_id = ?2)", params![mid.as_slice(), msg.conversation_id.0.as_slice()], |row| row.get(0), )?; if exists { return Ok(false); } } self.save_message(msg)?; Ok(true) } // ── Sequence tracking ────────────────────────────────────────────── pub fn update_last_seen_seq(&self, id: &ConversationId, seq: u64) -> anyhow::Result<()> { self.conn.execute( "UPDATE conversations SET last_seen_seq = ?2 WHERE id = ?1 AND last_seen_seq < ?2", params![id.0.as_slice(), seq as i64], )?; Ok(()) } // ── Outbox (offline queue) ──────────────────────────────────────── pub fn enqueue_outbox( &self, conv_id: &ConversationId, recipient_key: &[u8], payload: &[u8], ) -> anyhow::Result<()> { self.conn.execute( "INSERT INTO outbox (conversation_id, recipient_key, payload, created_at_ms) VALUES (?1, ?2, ?3, ?4)", params![conv_id.0.as_slice(), recipient_key, payload, now_ms() as i64], )?; Ok(()) } pub fn load_pending_outbox(&self) -> anyhow::Result> { let mut stmt = self.conn.prepare( "SELECT id, conversation_id, recipient_key, payload, retry_count FROM outbox WHERE status = 'pending' ORDER BY created_at_ms", )?; let rows = stmt.query_map([], |row| { let id: i64 = row.get(0)?; let conv_blob: Vec = row.get(1)?; let recipient_key: Vec = row.get(2)?; let payload: Vec = row.get(3)?; let retry_count: u32 = row.get(4)?; Ok(OutboxEntry { id, conversation_id: ConversationId::from_slice(&conv_blob) .unwrap_or(ConversationId([0; 16])), recipient_key, payload, retry_count, }) })?; let mut entries = Vec::new(); for row in rows { entries.push(row?); } Ok(entries) } pub fn mark_outbox_sent(&self, id: i64) -> anyhow::Result<()> { self.conn.execute( "UPDATE outbox SET status = 'sent' WHERE id = ?1", params![id], )?; Ok(()) } pub fn mark_outbox_failed(&self, id: i64, retry_count: u32) -> anyhow::Result<()> { let new_status = if retry_count > 5 { "failed" } else { "pending" }; self.conn.execute( "UPDATE outbox SET retry_count = ?2, status = ?3 WHERE id = ?1", params![id, retry_count, new_status], )?; Ok(()) } } /// An entry in the offline outbox queue. #[derive(Clone, Debug)] pub struct OutboxEntry { pub id: i64, pub conversation_id: ConversationId, pub recipient_key: Vec, pub payload: Vec, pub retry_count: u32, } pub fn now_ms() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_millis() as u64 }