2026-02-27 12:53:13 +08:00
|
|
|
//! 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),
|
2026-02-27 14:09:18 +08:00
|
|
|
name TEXT NOT NULL,
|
2026-02-27 12:53:13 +08:00
|
|
|
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<Self, StorageError> {
|
|
|
|
|
let db = SqliteDb::new(config)?;
|
|
|
|
|
Self::run_migration(db)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Creates a new chat storage with the given database.
|
|
|
|
|
fn run_migration(db: SqliteDb) -> Result<Self, StorageError> {
|
|
|
|
|
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> {
|
|
|
|
|
self.db.connection().execute(
|
2026-02-27 14:09:18 +08:00
|
|
|
"INSERT OR REPLACE INTO identity (id, name, secret_key) VALUES (1, ?1, ?2)",
|
|
|
|
|
params![
|
|
|
|
|
identity.get_name(),
|
|
|
|
|
identity.secret().DANGER_to_bytes().as_slice()
|
|
|
|
|
],
|
2026-02-27 12:53:13 +08:00
|
|
|
)?;
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Loads the identity if it exists.
|
|
|
|
|
pub fn load_identity(&self) -> Result<Option<Identity>, StorageError> {
|
|
|
|
|
let mut stmt = self
|
|
|
|
|
.db
|
|
|
|
|
.connection()
|
2026-02-27 14:09:18 +08:00
|
|
|
.prepare("SELECT name, secret_key FROM identity WHERE id = 1")?;
|
2026-02-27 12:53:13 +08:00
|
|
|
|
|
|
|
|
let result = stmt.query_row([], |row| {
|
2026-02-27 14:09:18 +08:00
|
|
|
let name: String = row.get(0)?;
|
|
|
|
|
let secret_key: Vec<u8> = row.get(1)?;
|
|
|
|
|
Ok((name, secret_key))
|
2026-02-27 12:53:13 +08:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
match result {
|
2026-02-27 14:09:18 +08:00
|
|
|
Ok((name, secret_key)) => {
|
2026-02-27 12:53:13 +08:00
|
|
|
let bytes: [u8; 32] = secret_key
|
|
|
|
|
.try_into()
|
|
|
|
|
.map_err(|_| StorageError::InvalidData("Invalid secret key length".into()))?;
|
2026-02-27 14:09:18 +08:00
|
|
|
let record = IdentityRecord {
|
|
|
|
|
name,
|
|
|
|
|
secret_key: bytes,
|
|
|
|
|
};
|
2026-02-27 12:53:13 +08:00
|
|
|
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<Option<StaticSecret>, 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<u8> = 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<Vec<String>, 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<bool, StorageError> {
|
|
|
|
|
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<Option<String>, 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
|
2026-02-27 14:09:18 +08:00
|
|
|
let identity = Identity::new("default");
|
|
|
|
|
let pubkey = identity.public_key();
|
2026-02-27 12:53:13 +08:00
|
|
|
storage.save_identity(&identity).unwrap();
|
|
|
|
|
|
|
|
|
|
// Load identity
|
|
|
|
|
let loaded = storage.load_identity().unwrap().unwrap();
|
2026-02-27 14:09:18 +08:00
|
|
|
assert_eq!(loaded.public_key(), pubkey);
|
2026-02-27 12:53:13 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[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"]);
|
|
|
|
|
}
|
|
|
|
|
}
|