mirror of
https://github.com/logos-messaging/libchat.git
synced 2026-04-01 17:13:13 +00:00
chore: merge latest main
This commit is contained in:
commit
66c2abe6de
7
Cargo.lock
generated
7
Cargo.lock
generated
@ -119,6 +119,13 @@ dependencies = [
|
|||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "client"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"libchat",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "const-oid"
|
name = "const-oid"
|
||||||
version = "0.9.6"
|
version = "0.9.6"
|
||||||
|
|||||||
12
Cargo.toml
12
Cargo.toml
@ -3,15 +3,17 @@
|
|||||||
resolver = "3"
|
resolver = "3"
|
||||||
|
|
||||||
members = [
|
members = [
|
||||||
"conversations",
|
"core/conversations",
|
||||||
"crypto",
|
"core/crypto",
|
||||||
"double-ratchets",
|
"core/double-ratchets",
|
||||||
"storage",
|
"core/storage",
|
||||||
|
"crates/client",
|
||||||
]
|
]
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
blake2 = "0.10"
|
blake2 = "0.10"
|
||||||
storage = { path = "storage" }
|
libchat = { path = "core/conversations" }
|
||||||
|
storage = { path = "core/storage" }
|
||||||
|
|
||||||
# Panicking across FFI boundaries is UB; abort is the correct strategy for a
|
# Panicking across FFI boundaries is UB; abort is the correct strategy for a
|
||||||
# C FFI library.
|
# C FFI library.
|
||||||
|
|||||||
@ -1 +0,0 @@
|
|||||||
pub mod utils;
|
|
||||||
@ -1,8 +0,0 @@
|
|||||||
use safer_ffi::prelude::*;
|
|
||||||
|
|
||||||
#[derive_ReprC]
|
|
||||||
#[repr(C)]
|
|
||||||
pub struct CResult<T: ReprC, Err: ReprC> {
|
|
||||||
pub ok: Option<T>,
|
|
||||||
pub err: Option<Err>,
|
|
||||||
}
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
-- 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
|
|
||||||
);
|
|
||||||
@ -1,7 +0,0 @@
|
|||||||
-- Ephemeral keys for inbox handshakes
|
|
||||||
-- Migration: 002_ephemeral_keys
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS ephemeral_keys (
|
|
||||||
public_key_hex TEXT PRIMARY KEY,
|
|
||||||
secret_key BLOB NOT NULL
|
|
||||||
);
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
-- Conversations metadata
|
|
||||||
-- Migration: 003_conversations
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS conversations (
|
|
||||||
local_convo_id TEXT PRIMARY KEY,
|
|
||||||
remote_convo_id TEXT NOT NULL,
|
|
||||||
convo_type TEXT NOT NULL,
|
|
||||||
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
|
|
||||||
);
|
|
||||||
@ -1,7 +0,0 @@
|
|||||||
//! Storage module for persisting chat state.
|
|
||||||
|
|
||||||
mod db;
|
|
||||||
mod migrations;
|
|
||||||
pub(crate) mod types;
|
|
||||||
|
|
||||||
pub(crate) use db::ChatStorage;
|
|
||||||
7
core/README.md
Normal file
7
core/README.md
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
# Core
|
||||||
|
|
||||||
|
Crates in this directory will one day be separated into a separate shared repository.
|
||||||
|
|
||||||
|
They could be moved now, but it's desirable to have a monorepo setup at this time.
|
||||||
|
|
||||||
|
These crates MUST not depend on any code outside of this folder.
|
||||||
@ -13,12 +13,9 @@ use safer_ffi::{
|
|||||||
prelude::{c_slice, repr_c},
|
prelude::{c_slice, repr_c},
|
||||||
};
|
};
|
||||||
|
|
||||||
use storage::StorageConfig;
|
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
context::{Context, Introduction},
|
context::{Context, Introduction},
|
||||||
errors::ChatError,
|
errors::ChatError,
|
||||||
ffi::utils::CResult,
|
|
||||||
types::ContentData,
|
types::ContentData,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -57,41 +54,6 @@ pub fn create_context(name: repr_c::String) -> repr_c::Box<ContextHandle> {
|
|||||||
Box::new(ContextHandle(Context::new_with_name(&*name))).into()
|
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> {
|
|
||||||
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.
|
/// Returns the friendly name of the contexts installation.
|
||||||
///
|
///
|
||||||
#[ffi_export]
|
#[ffi_export]
|
||||||
@ -186,11 +186,9 @@ impl Context {
|
|||||||
|
|
||||||
/// Persists a conversation's metadata and ratchet state to DB.
|
/// Persists a conversation's metadata and ratchet state to DB.
|
||||||
fn persist_convo(&mut self, convo: &dyn Convo) -> ConversationIdOwned {
|
fn persist_convo(&mut self, convo: &dyn Convo) -> ConversationIdOwned {
|
||||||
let _ = self.storage.save_conversation(
|
let _ = self
|
||||||
convo.id(),
|
.storage
|
||||||
&convo.remote_id(),
|
.save_conversation(convo.id(), &convo.remote_id(), convo.convo_type());
|
||||||
convo.convo_type(),
|
|
||||||
);
|
|
||||||
let _ = convo.save_ratchet_state(&mut self.ratchet_storage);
|
let _ = convo.save_ratchet_state(&mut self.ratchet_storage);
|
||||||
Arc::from(convo.id())
|
Arc::from(convo.id())
|
||||||
}
|
}
|
||||||
41
core/conversations/src/conversation/group_test.rs
Normal file
41
core/conversations/src/conversation/group_test.rs
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
use crate::{
|
||||||
|
conversation::{ChatError, ConversationId, Convo, Id},
|
||||||
|
proto::EncryptedPayload,
|
||||||
|
types::{AddressedEncryptedPayload, ContentData},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct GroupTestConvo {}
|
||||||
|
|
||||||
|
impl GroupTestConvo {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Id for GroupTestConvo {
|
||||||
|
fn id(&self) -> ConversationId<'_> {
|
||||||
|
// implementation
|
||||||
|
"grouptest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Convo for GroupTestConvo {
|
||||||
|
fn send_message(
|
||||||
|
&mut self,
|
||||||
|
_content: &[u8],
|
||||||
|
) -> Result<Vec<AddressedEncryptedPayload>, ChatError> {
|
||||||
|
Ok(vec![])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_frame(
|
||||||
|
&mut self,
|
||||||
|
_encoded_payload: EncryptedPayload,
|
||||||
|
) -> Result<Option<ContentData>, ChatError> {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remote_id(&self) -> String {
|
||||||
|
self.id().to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,7 +3,6 @@ mod context;
|
|||||||
mod conversation;
|
mod conversation;
|
||||||
mod crypto;
|
mod crypto;
|
||||||
mod errors;
|
mod errors;
|
||||||
mod ffi;
|
|
||||||
mod identity;
|
mod identity;
|
||||||
mod inbox;
|
mod inbox;
|
||||||
mod proto;
|
mod proto;
|
||||||
@ -12,6 +11,8 @@ mod types;
|
|||||||
mod utils;
|
mod utils;
|
||||||
|
|
||||||
pub use api::*;
|
pub use api::*;
|
||||||
|
pub use context::{Context, Introduction};
|
||||||
|
pub use errors::ChatError;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
@ -1,12 +1,16 @@
|
|||||||
//! Chat-specific storage implementation.
|
//! Chat-specific storage implementation.
|
||||||
|
|
||||||
|
mod migrations;
|
||||||
|
mod types;
|
||||||
|
|
||||||
|
use crypto::PrivateKey;
|
||||||
use storage::{RusqliteError, SqliteDb, StorageConfig, StorageError, params};
|
use storage::{RusqliteError, SqliteDb, StorageConfig, StorageError, params};
|
||||||
use zeroize::Zeroize;
|
use zeroize::Zeroize;
|
||||||
|
|
||||||
use super::migrations;
|
use crate::{
|
||||||
use super::types::{ConversationRecord, IdentityRecord};
|
identity::Identity,
|
||||||
use crate::crypto::PrivateKey;
|
storage::types::{ConversationRecord, IdentityRecord},
|
||||||
use crate::identity::Identity;
|
};
|
||||||
|
|
||||||
/// Chat-specific storage operations.
|
/// Chat-specific storage operations.
|
||||||
///
|
///
|
||||||
@ -48,6 +52,46 @@ impl ChatStorage {
|
|||||||
Ok(())
|
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()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== Ephemeral Key Operations ====================
|
// ==================== Ephemeral Key Operations ====================
|
||||||
|
|
||||||
/// Saves an ephemeral key pair to storage.
|
/// Saves an ephemeral key pair to storage.
|
||||||
@ -162,9 +206,10 @@ impl ChatStorage {
|
|||||||
|
|
||||||
/// Loads all conversation records.
|
/// Loads all conversation records.
|
||||||
pub fn load_conversations(&self) -> Result<Vec<ConversationRecord>, StorageError> {
|
pub fn load_conversations(&self) -> Result<Vec<ConversationRecord>, StorageError> {
|
||||||
let mut stmt = self.db.connection().prepare(
|
let mut stmt = self
|
||||||
"SELECT local_convo_id, remote_convo_id, convo_type FROM conversations",
|
.db
|
||||||
)?;
|
.connection()
|
||||||
|
.prepare("SELECT local_convo_id, remote_convo_id, convo_type FROM conversations")?;
|
||||||
|
|
||||||
let records = stmt
|
let records = stmt
|
||||||
.query_map([], |row| {
|
.query_map([], |row| {
|
||||||
@ -187,48 +232,6 @@ impl ChatStorage {
|
|||||||
)?;
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Identity Operations (continued) ====================
|
|
||||||
|
|
||||||
/// 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)]
|
#[cfg(test)]
|
||||||
@ -7,20 +7,10 @@ use storage::{Connection, StorageError};
|
|||||||
|
|
||||||
/// Embeds and returns all migration SQL files in order.
|
/// Embeds and returns all migration SQL files in order.
|
||||||
pub fn get_migrations() -> Vec<(&'static str, &'static str)> {
|
pub fn get_migrations() -> Vec<(&'static str, &'static str)> {
|
||||||
vec![
|
vec![(
|
||||||
(
|
"001_initial_schema",
|
||||||
"001_initial_schema",
|
include_str!("migrations/001_initial_schema.sql"),
|
||||||
include_str!("migrations/001_initial_schema.sql"),
|
)]
|
||||||
),
|
|
||||||
(
|
|
||||||
"002_ephemeral_keys",
|
|
||||||
include_str!("migrations/002_ephemeral_keys.sql"),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"003_conversations",
|
|
||||||
include_str!("migrations/003_conversations.sql"),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Applies all migrations to the database.
|
/// Applies all migrations to the database.
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
-- 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
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Ephemeral keys for inbox handshakes
|
||||||
|
CREATE TABLE IF NOT EXISTS ephemeral_keys (
|
||||||
|
public_key_hex TEXT PRIMARY KEY,
|
||||||
|
secret_key BLOB NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Conversations metadata
|
||||||
|
CREATE TABLE IF NOT EXISTS conversations (
|
||||||
|
local_convo_id TEXT PRIMARY KEY,
|
||||||
|
remote_convo_id TEXT NOT NULL,
|
||||||
|
convo_type TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
|
||||||
|
);
|
||||||
@ -22,7 +22,6 @@ impl From<IdentityRecord> for Identity {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Record for storing conversation metadata.
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct ConversationRecord {
|
pub struct ConversationRecord {
|
||||||
pub local_convo_id: String,
|
pub local_convo_id: String,
|
||||||
10
crates/client/Cargo.toml
Normal file
10
crates/client/Cargo.toml
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
[package]
|
||||||
|
name = "client"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
crate-type = ["rlib"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
libchat = { workspace = true }
|
||||||
18
crates/client/src/client.rs
Normal file
18
crates/client/src/client.rs
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
use libchat::ChatError;
|
||||||
|
use libchat::Context;
|
||||||
|
|
||||||
|
pub struct ChatClient {
|
||||||
|
ctx: Context,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ChatClient {
|
||||||
|
pub fn new(name: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
ctx: Context::new_with_name(name),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_bundle(&mut self) -> Result<Vec<u8>, ChatError> {
|
||||||
|
self.ctx.create_intro_bundle()
|
||||||
|
}
|
||||||
|
}
|
||||||
3
crates/client/src/lib.rs
Normal file
3
crates/client/src/lib.rs
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
mod client;
|
||||||
|
|
||||||
|
pub use client::ChatClient;
|
||||||
Loading…
x
Reference in New Issue
Block a user