Skip to content
3 changes: 3 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions conversations/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,8 @@ rand_core = { version = "0.6" }
safer-ffi = "0.1.13"
thiserror = "2.0.17"
x25519-dalek = { version = "2.0.1", features = ["static_secrets", "reusable_secrets", "getrandom"] }
storage = { path = "../storage" }
zeroize = { version = "1.8.2", features = ["derive"] }

[dev-dependencies]
tempfile = "3"
38 changes: 38 additions & 0 deletions conversations/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,12 @@ use safer_ffi::{
prelude::{c_slice, repr_c},
};

use storage::StorageConfig;

use crate::{
context::{Context, Introduction},
errors::ChatError,
ffi::utils::CResult,
types::ContentData,
};

Expand Down Expand Up @@ -54,6 +57,41 @@ pub fn create_context(name: repr_c::String) -> repr_c::Box<ContextHandle> {
Box::new(ContextHandle(Context::new_with_name(&*name))).into()
}

/// Creates a new libchat Context with file-based persistent storage.
///
/// The identity will be loaded from storage if it exists, or created and saved if not.
///
/// # Parameters
/// - name: Friendly name for the identity (used if creating new identity)
/// - db_path: Path to the SQLite database file
/// - db_secret: Secret key for encrypting the database
///
/// # Returns
/// CResult with context handle on success, or error string on failure.
/// On success, the context handle must be freed with `destroy_context()` after usage.
/// On error, the error string must be freed with `destroy_string()` after usage.
#[ffi_export]
pub fn create_context_with_storage(
name: repr_c::String,
db_path: repr_c::String,
db_secret: repr_c::String,
) -> CResult<repr_c::Box<ContextHandle>, repr_c::String> {
Comment on lines 73 to 78
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file’s ABI notes indicate using out-parameters for returning non-trivial structs (e.g., installation_name, create_intro_bundle). create_context_with_storage returns a CResult<...> struct by value, which is inconsistent with that convention and may be ABI-problematic for Nim callers. Consider switching to an out-parameter result struct to match the established pattern.

Copilot uses AI. Check for mistakes.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@osmaczko is this going to contribute to the issues with sret?

let config = StorageConfig::Encrypted {
path: db_path.to_string(),
key: db_secret.to_string(),
};
match Context::open(&*name, config) {
Ok(ctx) => CResult {
ok: Some(Box::new(ContextHandle(ctx)).into()),
err: None,
},
Err(e) => CResult {
ok: None,
err: Some(e.to_string().into()),
},
}
}

/// Returns the friendly name of the contexts installation.
///
#[ffi_export]
Expand Down
67 changes: 62 additions & 5 deletions conversations/src/context.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
use std::rc::Rc;

use storage::StorageConfig;

use crate::{
conversation::{ConversationId, ConversationStore, Convo, Id},
errors::ChatError,
identity::Identity,
inbox::Inbox,
proto::{EncryptedPayload, EnvelopeV1, Message},
storage::ChatStorage,
types::{AddressedEnvelope, ContentData},
};

Expand All @@ -18,17 +21,44 @@ pub struct Context {
_identity: Rc<Identity>,
store: ConversationStore,
inbox: Inbox,
#[allow(dead_code)] // Will be used for conversation persistence
storage: ChatStorage,
}

impl Context {
pub fn new_with_name(name: impl Into<String>) -> 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<String>, config: StorageConfig) -> Result<Self, ChatError> {
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<String>) -> Self {
Self::open(name, StorageConfig::InMemory).expect("in-memory storage should not fail")
}

pub fn installation_name(&self) -> &str {
Expand Down Expand Up @@ -195,4 +225,31 @@ mod tests {
send_and_verify(&mut saro, &mut raya, &saro_convo_id, &content);
}
}

#[test]
fn identity_persistence() {
// Use file-based storage to test real persistence
let dir = tempfile::tempdir().unwrap();
let db_path = dir
.path()
.join("test_identity.db")
.to_string_lossy()
.to_string();
let config = StorageConfig::File(db_path);

// Create context - this should create and save a new identity
let ctx1 = Context::open("alice", config.clone()).unwrap();
let pubkey1 = ctx1._identity.public_key();
let name1 = ctx1.installation_name().to_string();

// Drop and reopen - should load the same identity
drop(ctx1);
let ctx2 = Context::open("alice", config).unwrap();
let pubkey2 = ctx2._identity.public_key();
let name2 = ctx2.installation_name().to_string();

// Identity should be the same
assert_eq!(pubkey1, pubkey2, "public key should persist");
assert_eq!(name1, name2, "name should persist");
}
}
4 changes: 4 additions & 0 deletions conversations/src/errors.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
pub use thiserror::Error;

use storage::StorageError;

#[derive(Error, Debug)]
pub enum ChatError {
#[error("protocol error: {0:?}")]
Expand All @@ -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)]
Expand Down
1 change: 1 addition & 0 deletions conversations/src/ffi/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
pub mod utils;
8 changes: 8 additions & 0 deletions conversations/src/ffi/utils.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
use safer_ffi::prelude::*;

#[derive_ReprC]
#[repr(C)]
pub struct CResult<T: ReprC, Err: ReprC> {
pub ok: Option<T>,
pub err: Option<Err>,
}
Comment on lines +5 to +8
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From a consistency POV, api.rs uses the safer_ffi to create unique types per each api_call. I'm not saying that is the best approach, however this does represent a different approach of using generic containers as return values.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exactly, that's why I put in ffi/utils, I hope it can be reused in other apis, so that we can have uniform apis and reused code.

7 changes: 7 additions & 0 deletions conversations/src/identity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,13 @@ impl Identity {
}
}

pub fn from_secret(name: impl Into<String>, secret: PrivateKey) -> Self {
Self {
name: name.into(),
secret,
}
}

pub fn public_key(&self) -> PublicKey {
PublicKey::from(&self.secret)
}
Expand Down
2 changes: 2 additions & 0 deletions conversations/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@ mod context;
mod conversation;
mod crypto;
mod errors;
mod ffi;
mod identity;
mod inbox;
mod proto;
mod storage;
mod types;
mod utils;

Expand Down
111 changes: 111 additions & 0 deletions conversations/src/storage/db.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
//! Chat-specific storage implementation.

use storage::{RusqliteError, SqliteDb, StorageConfig, StorageError, params};
use zeroize::Zeroize;

use super::migrations;
use super::types::IdentityRecord;
use crate::identity::Identity;

/// Chat-specific storage operations.
///
/// This struct wraps a SqliteDb and provides domain-specific
/// storage operations for chat state (identity, inbox keys, chat metadata).
///
/// Note: Ratchet state persistence is delegated to double_ratchets::RatchetStorage.
pub struct ChatStorage {
db: SqliteDb,
}

impl ChatStorage {
/// Creates a new ChatStorage with the given configuration.
pub fn new(config: StorageConfig) -> Result<Self, StorageError> {
let db = SqliteDb::new(config)?;
Self::run_migrations(db)
}

/// Applies all migrations and returns the storage instance.
fn run_migrations(mut db: SqliteDb) -> Result<Self, StorageError> {
migrations::apply_migrations(db.connection_mut())?;
Ok(Self { db })
}

// ==================== Identity Operations ====================

/// Saves the identity (secret key).
///
/// Note: The secret key bytes are explicitly zeroized after use to minimize
/// the time sensitive data remains in stack memory.
pub fn save_identity(&mut self, identity: &Identity) -> Result<(), StorageError> {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Sand] Clearing up the language used in this library: Installation represent keys for this given instance, where Identity represents a group of installations tied to a set of Installations.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clear the Installation or identity terms used is out of scope for this PR.
We need to write the spec first, also there may need org level consensus on how such concept being shared or reused.

let mut secret_bytes = identity.secret().DANGER_to_bytes();
let result = self.db.connection().execute(
"INSERT OR REPLACE INTO identity (id, name, secret_key) VALUES (1, ?1, ?2)",
params![identity.get_name(), secret_bytes.as_slice()],
);
secret_bytes.zeroize();
result?;
Ok(())
}

/// Loads the identity if it exists.
///
/// Note: Secret key bytes are zeroized after being copied into IdentityRecord,
/// which handles its own zeroization via ZeroizeOnDrop.
pub fn load_identity(&self) -> Result<Option<Identity>, StorageError> {
let mut stmt = self
.db
.connection()
.prepare("SELECT name, secret_key FROM identity WHERE id = 1")?;

let result = stmt.query_row([], |row| {
let name: String = row.get(0)?;
let secret_key: Vec<u8> = row.get(1)?;
Ok((name, secret_key))
});

match result {
Ok((name, mut secret_key_vec)) => {
let bytes: Result<[u8; 32], _> = secret_key_vec.as_slice().try_into();
let bytes = match bytes {
Ok(b) => b,
Err(_) => {
secret_key_vec.zeroize();
return Err(StorageError::InvalidData(
"Invalid secret key length".into(),
));
}
};
secret_key_vec.zeroize();
let record = IdentityRecord {
name,
secret_key: bytes,
};
Ok(Some(Identity::from(record)))
}
Err(RusqliteError::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(e.into()),
}
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_identity_roundtrip() {
let mut storage = ChatStorage::new(StorageConfig::InMemory).unwrap();

// Initially no identity
assert!(storage.load_identity().unwrap().is_none());

// Save identity
let identity = Identity::new("default");
let pubkey = identity.public_key();
storage.save_identity(&identity).unwrap();

// Load identity
let loaded = storage.load_identity().unwrap().unwrap();
assert_eq!(loaded.public_key(), pubkey);
}
}
46 changes: 46 additions & 0 deletions conversations/src/storage/migrations.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
//! Database migrations module.
//!
//! SQL migrations are embedded at compile time and applied in order.
//! Each migration is applied atomically within a transaction.

use storage::{Connection, StorageError};

/// Embeds and returns all migration SQL files in order.
pub fn get_migrations() -> Vec<(&'static str, &'static str)> {
vec![(
"001_initial_schema",
include_str!("migrations/001_initial_schema.sql"),
)]
}

/// Applies all migrations to the database.
///
/// Uses a simple version tracking table to avoid re-running migrations.
pub fn apply_migrations(conn: &mut Connection) -> Result<(), StorageError> {
// Create migrations tracking table if it doesn't exist
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS _migrations (
name TEXT PRIMARY KEY,
applied_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
);",
)?;

for (name, sql) in get_migrations() {
// Check if migration already applied
let already_applied: bool = conn.query_row(
"SELECT EXISTS(SELECT 1 FROM _migrations WHERE name = ?1)",
[name],
|row| row.get(0),
)?;

if !already_applied {
// Apply migration and record it atomically in a transaction
let tx = conn.transaction()?;
tx.execute_batch(sql)?;
tx.execute("INSERT INTO _migrations (name) VALUES (?1)", [name])?;
tx.commit()?;
}
}

Ok(())
}
9 changes: 9 additions & 0 deletions conversations/src/storage/migrations/001_initial_schema.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
-- Initial schema for chat storage
-- Migration: 001_initial_schema

-- Identity table (single row)
CREATE TABLE IF NOT EXISTS identity (
id INTEGER PRIMARY KEY CHECK (id = 1),
name TEXT NOT NULL,
secret_key BLOB NOT NULL
);
Loading