Merge branch 'main' of github.com:logos-messaging/libchat into storage-crate

This commit is contained in:
kaichaosun 2026-01-29 09:26:57 +08:00
commit 5626dad62a
No known key found for this signature in database
GPG Key ID: 223E0F992F4F03BF
22 changed files with 1214 additions and 246 deletions

3
.gitignore vendored
View File

@ -24,6 +24,9 @@ target
# Compiled binary
**/ffi_nim_example
/nim-bindings/examples/pingpong
/nim-bindings/libchat
# Temporary data folder
tmp

14
Cargo.lock generated
View File

@ -222,6 +222,7 @@ dependencies = [
"rand",
"rand_core",
"safer-ffi",
"serde",
"storage",
"thiserror",
"x25519-dalek",
@ -473,6 +474,7 @@ dependencies = [
"hex",
"prost",
"rand_core",
"safer-ffi",
"thiserror",
"x25519-dalek",
]
@ -507,9 +509,9 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "openssl-src"
version = "300.5.4+3.5.4"
version = "300.5.5+3.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a507b3792995dae9b0df8a1c1e3771e8418b7c2d9f0baeba32e6fe8b06c7cb72"
checksum = "3f1787d533e03597a7934fd0a765f0d28e94ecc5fb7789f8053b1e699a56f709"
dependencies = [
"cc",
]
@ -1045,18 +1047,18 @@ dependencies = [
[[package]]
name = "zerocopy"
version = "0.8.34"
version = "0.8.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71ddd76bcebeed25db614f82bf31a9f4222d3fbba300e6fb6c00afa26cbd4d9d"
checksum = "fdea86ddd5568519879b8187e1cf04e24fce28f7fe046ceecbce472ff19a2572"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.34"
version = "0.8.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8187381b52e32220d50b255276aa16a084ec0a9017a0ca2152a1f55c539758d"
checksum = "0c15e1b46eff7c6c91195752e0eeed8ef040e391cdece7c25376957d5f15df22"
dependencies = [
"proc-macro2",
"quote",

View File

@ -4,7 +4,7 @@ version = "0.1.0"
edition = "2024"
[lib]
crate-type = ["staticlib"]
crate-type = ["staticlib","dylib"]
[dependencies]
blake2.workspace = true
@ -13,5 +13,6 @@ crypto = { path = "../crypto" }
hex = "0.4.3"
prost = "0.14.1"
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"] }

View File

@ -1,25 +1,31 @@
use core::ffi::c_char;
use std::{ffi::CStr, slice};
use safer_ffi::prelude::*;
// Must only contain negative values, values cannot be changed once set.
#[repr(i32)]
pub enum ErrorCode {
None = 0,
BadPtr = -1,
BadConvoId = -2,
BadIntro = -3,
NotImplemented = -4,
BufferExceeded = -5,
UnknownError = -6,
}
use crate::context::Context;
use crate::context::{Context, ConvoHandle, Introduction};
pub type ContextHandle = *mut Context;
/// Opaque wrapper for Context
#[derive_ReprC]
#[repr(opaque)]
pub struct ContextHandle(pub(crate) Context);
/// Creates a new libchat Ctx
///
/// # Returns
/// Opaque handle to the store. Must be freed with conversation_store_destroy()
#[unsafe(no_mangle)]
pub extern "C" fn create_context() -> ContextHandle {
let store = Box::new(Context::new());
Box::into_raw(store) // Leak the box, return raw pointer
/// Opaque handle to the store. Must be freed with destroy_context()
#[ffi_export]
pub fn create_context() -> repr_c::Box<ContextHandle> {
Box::new(ContextHandle(Context::new())).into()
}
/// Destroys a conversation store and frees its memory
@ -28,147 +34,176 @@ pub extern "C" fn create_context() -> ContextHandle {
/// - handle must be a valid pointer from conversation_store_create()
/// - handle must not be used after this call
/// - handle must not be freed twice
#[unsafe(no_mangle)]
pub unsafe extern "C" fn destroy_context(handle: ContextHandle) {
if !handle.is_null() {
unsafe {
let _ = Box::from_raw(handle); // Reconstruct box and drop it
}
}
#[ffi_export]
pub fn destroy_context(ctx: repr_c::Box<ContextHandle>) {
drop(ctx);
}
/// Encrypts/encodes content into payloads.
/// There may be multiple payloads generated from a single content.
/// Creates an intro bundle for sharing with other users
///
/// # Returns
/// Returns the number of payloads created.
///
/// # Errors
/// Negative numbers symbolize an error has occured. See `ErrorCode`
///
#[unsafe(no_mangle)]
pub unsafe extern "C" fn generate_payload(
// Input: Context Handle
handle: ContextHandle,
// Input: Conversation_id
conversation_id: *const c_char,
// Input: Content array
content: *const u8,
content_len: usize,
/// Returns the number of bytes written to bundle_out
/// Check error_code field: 0 means success, negative values indicate errors (see ErrorCode).
#[ffi_export]
pub fn create_intro_bundle(ctx: &mut ContextHandle, mut bundle_out: c_slice::Mut<'_, u8>) -> i32 {
let Ok(bundle) = ctx.0.create_intro_bundle() else {
return ErrorCode::UnknownError as i32;
};
max_payload_count: usize,
// Output: Addresses
addrs: *const *mut c_char,
addr_max_len: usize,
// Output: Frame data
payload_buffer_ptrs: *const *mut u8,
payload_buffer_max_len: *const usize, //Single Value
// Output: Array - Number of bytes written to each payload
output_actual_lengths: *mut usize,
) -> i32 {
if handle.is_null() || content.is_null() || payload_buffer_ptrs.is_null() || addrs.is_null() {
return ErrorCode::BadPtr as i32;
// Check buffer is large enough
if bundle_out.len() < bundle.len() {
return ErrorCode::BufferExceeded as i32;
}
unsafe {
let ctx = &mut *handle;
let content_slice = slice::from_raw_parts(content, content_len);
let payload_ptrs_slice = slice::from_raw_parts(payload_buffer_ptrs, max_payload_count);
let payload_max_len = if !payload_buffer_max_len.is_null() {
*payload_buffer_max_len
} else {
return ErrorCode::BadPtr as i32;
};
let addrs_slice = slice::from_raw_parts(addrs, max_payload_count);
let actual_lengths_slice =
slice::from_raw_parts_mut(output_actual_lengths, max_payload_count);
let c_str = CStr::from_ptr(conversation_id);
let id_str = match c_str.to_str() {
Ok(s) => s,
Err(_) => return ErrorCode::BadConvoId as i32,
};
// Call ctx.send_content to get payloads
let payloads = ctx.send_content(id_str, content_slice);
// Check if we have enough output buffers
if payloads.len() > max_payload_count {
return ErrorCode::BadPtr as i32; // Not enough output buffers
}
// Write each payload to the output buffers
for (i, payload) in payloads.iter().enumerate() {
let payload_ptr = payload_ptrs_slice[i];
let addr_ptr = addrs_slice[i];
// Write payload data
if !payload_ptr.is_null() {
let payload_buf = slice::from_raw_parts_mut(payload_ptr, payload_max_len);
let copy_len = payload.data.len().min(payload_max_len);
payload_buf[..copy_len].copy_from_slice(&payload.data[..copy_len]);
actual_lengths_slice[i] = copy_len;
} else {
return ErrorCode::BadPtr as i32;
}
// Write delivery address
if !addr_ptr.is_null() {
let addr_bytes = payload.delivery_address.as_bytes();
let addr_buf = slice::from_raw_parts_mut(addr_ptr as *mut u8, addr_max_len);
let copy_len = addr_bytes.len().min(addr_max_len - 1);
addr_buf[..copy_len].copy_from_slice(&addr_bytes[..copy_len]);
addr_buf[copy_len] = 0; // Null-terminate
} else {
return ErrorCode::BadPtr as i32;
}
}
payloads.len() as i32
}
bundle_out[..bundle.len()].copy_from_slice(&bundle);
bundle.len() as i32
}
/// Decrypts/decodes payloads into content.
/// A payload may return 1 or 0 contents.
/// Creates a new private conversation
///
/// # Returns
/// Returns the number of bytes written to content
///
/// # Errors
/// Negative numbers symbolize an error has occured. See `ErrorCode`
///
#[unsafe(no_mangle)]
pub unsafe extern "C" fn handle_payload(
// Input: Context handle
handle: ContextHandle,
// Input: Payload data
payload_data: *const u8,
payload_len: usize,
/// Returns a struct with payloads that must be sent, the conversation_id that was created.
/// The NewConvoResult must be freed.
#[ffi_export]
pub fn create_new_private_convo(
ctx: &mut ContextHandle,
bundle: c_slice::Ref<'_, u8>,
content: c_slice::Ref<'_, u8>,
) -> NewConvoResult {
// Convert input bundle to Introduction
let Ok(intro) = Introduction::try_from(bundle.as_slice()) else {
return NewConvoResult {
error_code: ErrorCode::BadIntro as i32,
convo_id: 0,
payloads: Vec::new().into(),
};
};
// Output: Content
content: *mut u8,
content_max_len: usize,
) -> i32 {
if handle.is_null() || payload_data.is_null() || content.is_null() {
return ErrorCode::BadPtr as i32;
}
// Convert input content to String
let msg = String::from_utf8_lossy(&content).into_owned();
unsafe {
let ctx = &mut *handle;
let payload_slice = slice::from_raw_parts(payload_data, payload_len);
let content_buf = slice::from_raw_parts_mut(content, content_max_len);
// Create conversation
let (convo_handle, payloads) = ctx.0.create_private_convo(&intro, msg);
// Call ctx.handle_payload to decode the payload
let contents = ctx.handle_payload(payload_slice);
// Convert payloads to FFI-compatible vector
let ffi_payloads: Vec<Payload> = payloads
.into_iter()
.map(|p| Payload {
address: p.delivery_address.into(),
data: p.data.into(),
})
.collect();
if let Some(content_data) = contents {
let copy_len = content_data.data.len().min(content_max_len);
content_buf[..copy_len].copy_from_slice(&content_data.data[..copy_len]);
copy_len as i32
} else {
0 // No content produced
}
NewConvoResult {
error_code: 0,
convo_id: convo_handle,
payloads: ffi_payloads.into(),
}
}
/// Sends content to an existing conversation
///
/// # Returns
/// Returns a PayloadResult with payloads that must be delivered to participants.
/// Check error_code field: 0 means success, negative values indicate errors (see ErrorCode).
#[ffi_export]
pub fn send_content(
ctx: &mut ContextHandle,
convo_handle: ConvoHandle,
content: c_slice::Ref<'_, u8>,
) -> PayloadResult {
let payloads = ctx.0.send_content(convo_handle, &content);
let ffi_payloads: Vec<Payload> = payloads
.into_iter()
.map(|p| Payload {
address: p.delivery_address.into(),
data: p.data.into(),
})
.collect();
PayloadResult {
error_code: 0,
payloads: ffi_payloads.into(),
}
}
/// Handles an incoming payload and writes content to caller-provided buffers
///
/// # Returns
/// Returns the number of bytes written to data_out on success (>= 0).
/// Returns negative error code on failure (see ErrorCode).
/// conversation_id_out_len is set to the number of bytes written to conversation_id_out.
#[ffi_export]
pub fn handle_payload(
ctx: &mut ContextHandle,
payload: c_slice::Ref<'_, u8>,
mut conversation_id_out: c_slice::Mut<'_, u8>,
conversation_id_out_len: Out<'_, u32>,
mut content_out: c_slice::Mut<'_, u8>,
) -> i32 {
match ctx.0.handle_payload(&payload) {
Some(content) => {
let convo_id_bytes = content.conversation_id.as_bytes();
if conversation_id_out.len() < convo_id_bytes.len() {
return ErrorCode::BufferExceeded as i32;
}
if content_out.len() < content.data.len() {
return ErrorCode::BufferExceeded as i32;
}
conversation_id_out[..convo_id_bytes.len()].copy_from_slice(convo_id_bytes);
conversation_id_out_len.write(convo_id_bytes.len() as u32);
content_out[..content.data.len()].copy_from_slice(&content.data);
content.data.len() as i32
}
None => 0,
}
}
// ============================================================================
// safer_ffi implementation
// ===============================================================================================================================
/// Payload structure for FFI
#[derive(Debug)]
#[derive_ReprC]
#[repr(C)]
pub struct Payload {
pub address: repr_c::String,
pub data: repr_c::Vec<u8>,
}
/// Result structure for create_intro_bundle_safe
/// error_code is 0 on success, negative on error (see ErrorCode)
#[derive_ReprC]
#[repr(C)]
pub struct PayloadResult {
pub error_code: i32,
pub payloads: repr_c::Vec<Payload>,
}
/// Free the result from create_intro_bundle_safe
#[ffi_export]
pub fn destroy_payload_result(result: PayloadResult) {
drop(result);
}
/// Result structure for create_new_private_convo_safe
/// error_code is 0 on success, negative on error (see ErrorCode)
#[derive_ReprC]
#[repr(C)]
pub struct NewConvoResult {
pub error_code: i32,
pub convo_id: u32,
pub payloads: repr_c::Vec<Payload>,
}
/// Free the result from create_new_private_convo_safe
#[ffi_export]
pub fn destroy_convo_result(result: NewConvoResult) {
drop(result);
}

View File

@ -1,20 +1,30 @@
use std::{collections::HashMap, rc::Rc, sync::Arc};
use crate::{
conversation::{ConversationId, ConversationIdOwned, ConversationStore},
conversation::{ConversationId, ConversationStore, Convo, Id},
errors::ChatError,
identity::Identity,
inbox::Inbox,
proto,
types::{ContentData, PayloadData},
};
pub use crate::inbox::Introduction;
//Offset handles to make debuging easier
const INITIAL_CONVO_HANDLE: u32 = 0xF5000001;
/// Used to identify a conversation on the othersize of the FFI.
pub type ConvoHandle = u32;
// 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<Identity>,
store: ConversationStore,
inbox: Inbox,
buf_size: usize,
convo_handle_map: HashMap<u32, Arc<str>>,
next_convo_handle: ConvoHandle,
}
impl Context {
@ -25,27 +35,38 @@ impl Context {
_identity: identity,
store: ConversationStore::new(),
inbox,
buf_size: 0,
convo_handle_map: HashMap::new(),
next_convo_handle: INITIAL_CONVO_HANDLE,
}
}
pub fn buffer_size(&self) -> usize {
self.buf_size
}
pub fn set_buffer_size(&mut self, size: usize) {
self.buf_size = size
}
pub fn create_private_convo(
&mut self,
remote_bundle: &Introduction,
content: String,
) -> (ConversationIdOwned, Vec<PayloadData>) {
) -> (ConvoHandle, Vec<PayloadData>) {
let (convo, payloads) = self
.inbox
.invite_to_private_convo(remote_bundle, content)
.unwrap_or_else(|_| todo!("Log/Surface Error"));
let convo_id = self.store.insert_convo(convo);
(convo_id, payloads)
let convo_handle = self.add_convo(convo);
(convo_handle, payloads)
}
pub fn send_content(&mut self, _convo_id: ConversationId, _content: &[u8]) -> Vec<PayloadData> {
pub fn send_content(&mut self, convo_id: ConvoHandle, _content: &[u8]) -> Vec<PayloadData> {
// !TODO Replace Mock
vec![PayloadData {
delivery_address: _convo_id.into(),
delivery_address: format!("addr-for-{convo_id}"),
data: vec![40, 30, 20, 10],
}]
}
@ -57,6 +78,20 @@ impl Context {
data: vec![1, 2, 3, 4, 5, 6],
})
}
pub fn create_intro_bundle(&mut self) -> Result<Vec<u8>, ChatError> {
let pkb = self.inbox.create_bundle();
Ok(Introduction::from(pkb).into())
}
fn add_convo(&mut self, convo: impl Convo + Id + 'static) -> ConvoHandle {
let handle = self.next_convo_handle;
self.next_convo_handle += 1;
let convo_id = self.store.insert_convo(convo);
self.convo_handle_map.insert(handle, convo_id);
handle
}
}
#[cfg(test)]

View File

@ -4,6 +4,8 @@ pub use thiserror::Error;
pub enum ChatError {
#[error("protocol error: {0:?}")]
Protocol(String),
#[error("protocol error: Got {0:?} expected {1:?}")]
ProtocolExpectation(&'static str, String),
#[error("Failed to decode payload: {0}")]
DecodeError(#[from] prost::DecodeError),
#[error("incorrect bundle value: {0:?}")]

View File

@ -182,9 +182,9 @@ impl Inbox {
payload: proto::EncryptedPayload,
) -> Result<proto::InboxHandshakeV1, ChatError> {
let Some(proto::Encryption::InboxHandshake(handshake)) = payload.encryption else {
return Err(ChatError::Protocol(
"Expected inboxhandshake encryption".into(),
));
let got = format!("{:?}", payload.encryption);
return Err(ChatError::ProtocolExpectation("inboxhandshake", got));
};
Ok(handshake)

View File

@ -31,12 +31,11 @@ impl Into<Vec<u8>> for Introduction {
}
}
impl TryFrom<Vec<u8>> for Introduction {
impl TryFrom<&[u8]> for Introduction {
type Error = ChatError;
fn try_from(value: Vec<u8>) -> Result<Self, Self::Error> {
let str_value =
String::from_utf8(value).map_err(|_| ChatError::BadParsing("Introduction"))?;
fn try_from(value: &[u8]) -> Result<Self, Self::Error> {
let str_value = String::from_utf8_lossy(value);
let parts: Vec<&str> = str_value.splitn(3, ':').collect();
if parts[0] != "Bundle" {

View File

@ -15,104 +15,79 @@ pub use api::*;
mod tests {
use super::*;
use std::ffi::CString;
use std::str::FromStr;
#[test]
fn test_ffi() {}
#[test]
fn test_process_and_write() {
// Create Context
let ctx = create_context();
// Setup conversation_id
let conv_id = CString::new("test_conversation_123").unwrap();
// Setup content
let content = b"Hello, World!";
// Setup output buffers for addresses (labels)
let addr_max_len = 256;
let mut addr_buffer1: Vec<u8> = vec![0; addr_max_len];
let mut addr_buffer2: Vec<u8> = vec![0; addr_max_len];
let addr_ptrs: Vec<*mut i8> = vec![
addr_buffer1.as_mut_ptr() as *mut i8,
addr_buffer2.as_mut_ptr() as *mut i8,
];
// Setup payload buffers
let max_payload_count = 2;
let payload_max_len = 1024;
let mut payload1: Vec<u8> = vec![0; payload_max_len];
let mut payload2: Vec<u8> = vec![0; payload_max_len];
let payload_ptrs: Vec<*mut u8> = vec![payload1.as_mut_ptr(), payload2.as_mut_ptr()];
let payload_max_lens: Vec<usize> = vec![payload_max_len, payload_max_len];
let mut actual_lengths: Vec<usize> = vec![0; max_payload_count];
// Call the FFI function
let result = unsafe {
generate_payload(
ctx,
conv_id.as_ptr(),
content.as_ptr(),
content.len(),
max_payload_count,
addr_ptrs.as_ptr(),
addr_max_len,
payload_ptrs.as_ptr(),
payload_max_lens.as_ptr(),
actual_lengths.as_mut_ptr(),
)
};
// Verify results
assert_eq!(result, 1, "Function should return 1 on success");
// Check that the conversation ID was written to the first label buffer
let written_addr = std::ffi::CStr::from_bytes_until_nul(&addr_buffer1)
.unwrap()
.to_str()
.unwrap();
assert_eq!(written_addr, "test_conversation_123");
fn test_invite_convo() {
let mut ctx = create_context();
let mut bundle = vec![0u8; 200];
let bundle_len = create_intro_bundle(&mut ctx, (&mut bundle[..]).into());
unsafe {
destroy_context(ctx);
bundle.set_len(bundle_len as usize);
}
assert!(bundle_len > 0, "bundle failed: {}", bundle_len);
let content = b"Hello";
let result = create_new_private_convo(&mut ctx, bundle[..].into(), content[..].into());
assert!(result.error_code == 0, "Error: {}", result.error_code);
destroy_context(ctx);
}
#[test]
fn test_process_and_write_null_ptr() {
use std::ptr;
// Create Context
let ctx = create_context();
let conv_id = CString::new("test").unwrap();
let content = b"test";
// Test with null content pointer
let result = unsafe {
generate_payload(
ctx,
conv_id.as_ptr(),
ptr::null(),
content.len(),
1,
ptr::null(),
256,
ptr::null(),
ptr::null(),
ptr::null_mut(),
)
};
fn test_message_roundtrip() {
let mut saro = create_context();
let mut raya = create_context();
let mut raya_bundle = vec![0u8; 200];
let bundle_len = create_intro_bundle(&mut raya, (&mut raya_bundle[..]).into());
unsafe {
destroy_context(ctx);
raya_bundle.set_len(bundle_len as usize);
}
assert_eq!(result, -1, "Should return ERR_BAD_PTR for null pointer");
assert!(bundle_len > 0, "bundle failed: {}", bundle_len);
let content = String::from_str("Hello").unwrap();
let result = create_new_private_convo(
&mut saro,
raya_bundle.as_slice().into(),
content.as_bytes().into(),
);
assert!(result.error_code == 0, "Error: {}", result.error_code);
// Handle payloads on raya's side
let mut conversation_id_out = vec![0u8; 256];
let mut conversation_id_out_len: u32 = 0;
let mut content_out = vec![0u8; 256];
for p in result.payloads.iter() {
let bytes_written = handle_payload(
&mut raya,
p.data[..].into(),
(&mut conversation_id_out[..]).into(),
(&mut conversation_id_out_len).into(),
(&mut content_out[..]).into(),
);
unsafe {
content_out.set_len(bytes_written as usize);
}
assert!(
bytes_written >= 0,
"handle_payload failed: {}",
bytes_written
);
//TODO: Verify output match
}
destroy_context(saro);
destroy_context(raya);
}
}

View File

@ -21,6 +21,7 @@ blake2 = "0.10.6"
safer-ffi = "0.1.13"
zeroize = "1.8.2"
storage = { workspace = true }
serde = "1.0"
[features]
headers = ["safer-ffi/headers"]

View File

@ -0,0 +1,75 @@
use double_ratchets::{InstallationKeyPair, RatchetState, hkdf::PrivateV1Domain};
fn main() {
// === Initial shared secret (X3DH / prekey result in real systems) ===
let shared_secret = [42u8; 32];
let bob_dh = InstallationKeyPair::generate();
let mut alice: RatchetState<PrivateV1Domain> =
RatchetState::init_sender(shared_secret, bob_dh.public().clone());
let mut bob: RatchetState<PrivateV1Domain> = RatchetState::init_receiver(shared_secret, bob_dh);
let (ciphertext, header) = alice.encrypt_message(b"Hello Bob!");
// === Bob receives ===
let plaintext = bob.decrypt_message(&ciphertext, header);
println!(
"Bob received: {}",
String::from_utf8_lossy(&plaintext.unwrap())
);
// === Bob replies (triggers DH ratchet) ===
let (ciphertext, header) = bob.encrypt_message(b"Hi Alice!");
let plaintext = alice.decrypt_message(&ciphertext, header);
println!(
"Alice received: {}",
String::from_utf8_lossy(&plaintext.unwrap())
);
// === Serialize the state of alice and bob ===
println!("Before restart, persist the state");
let alice_state = alice.as_bytes();
let bob_state = bob.as_bytes();
// === Deserialize alice and bob state from bytes ===
println!("Restart alice and bob");
let mut alice_new: RatchetState<PrivateV1Domain> =
RatchetState::from_bytes(&alice_state).unwrap();
let mut bob_new: RatchetState<PrivateV1Domain> = RatchetState::from_bytes(&bob_state).unwrap();
// === Alice sends a message ===
let (ciphertext, header) = alice_new.encrypt_message(b"Hello Bob!");
// === Bob receives ===
let plaintext = bob_new.decrypt_message(&ciphertext, header);
println!(
"New Bob received: {}",
String::from_utf8_lossy(&plaintext.unwrap())
);
// === Bob replies (triggers DH ratchet) ===
let (ciphertext, header) = bob_new.encrypt_message(b"Hi Alice!");
let plaintext = alice_new.decrypt_message(&ciphertext, header);
println!(
"New Alice received: {}",
String::from_utf8_lossy(&plaintext.unwrap())
);
let (skipped_ciphertext, skipped_header) = bob_new.encrypt_message(b"Hi Alice skipped!");
let (resumed_ciphertext, resumed_header) = bob_new.encrypt_message(b"Hi Alice resumed!");
let plaintext = alice_new.decrypt_message(&resumed_ciphertext, resumed_header);
println!(
"New Alice received: {}",
String::from_utf8_lossy(&plaintext.unwrap())
);
let plaintext = alice_new.decrypt_message(&skipped_ciphertext, skipped_header);
println!(
"New Alice received: {}",
String::from_utf8_lossy(&plaintext.unwrap())
);
}

View File

@ -23,4 +23,7 @@ pub enum RatchetError {
#[error("missing receiving chain")]
MissingReceivingChain,
#[error("deserialization failed")]
DeserializationFailed,
}

View File

@ -25,12 +25,12 @@ impl InstallationKeyPair {
&self.public
}
/// Export the secret key as raw bytes for storage.
pub fn secret_bytes(&self) -> [u8; 32] {
self.secret.to_bytes()
/// Export the secret key as raw bytes for serialization/storage.
pub fn secret_bytes(&self) -> &[u8; 32] {
self.secret.as_bytes()
}
/// Reconstruct from secret key bytes.
/// Import the secret key from raw bytes.
pub fn from_secret_bytes(bytes: [u8; 32]) -> Self {
let secret = StaticSecret::from(bytes);
let public = PublicKey::from(&secret);

View File

@ -3,6 +3,7 @@ pub mod errors;
pub mod ffi;
pub mod hkdf;
pub mod keypair;
pub mod reader;
pub mod state;
pub mod storage;
pub mod types;

View File

@ -0,0 +1,135 @@
use crate::errors::RatchetError;
pub struct Reader<'a> {
data: &'a [u8],
pos: usize,
}
impl<'a> Reader<'a> {
pub fn new(data: &'a [u8]) -> Self {
Self { data, pos: 0 }
}
pub fn read_bytes(&mut self, n: usize) -> Result<&[u8], RatchetError> {
if self.pos + n > self.data.len() {
return Err(RatchetError::DeserializationFailed);
}
let slice = &self.data[self.pos..self.pos + n];
self.pos += n;
Ok(slice)
}
pub fn read_array<const N: usize>(&mut self) -> Result<[u8; N], RatchetError> {
self.read_bytes(N)?
.try_into()
.map_err(|_| RatchetError::DeserializationFailed)
}
pub fn read_u8(&mut self) -> Result<u8, RatchetError> {
Ok(self.read_bytes(1)?[0])
}
pub fn read_u32(&mut self) -> Result<u32, RatchetError> {
Ok(u32::from_be_bytes(self.read_array()?))
}
pub fn read_option(&mut self) -> Result<Option<[u8; 32]>, RatchetError> {
match self.read_u8()? {
0x00 => Ok(None),
0x01 => Ok(Some(self.read_array()?)),
_ => Err(RatchetError::DeserializationFailed),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_read_bytes() {
let data = [1, 2, 3, 4, 5];
let mut reader = Reader::new(&data);
assert_eq!(reader.read_bytes(2).unwrap(), &[1, 2]);
assert_eq!(reader.read_bytes(3).unwrap(), &[3, 4, 5]);
}
#[test]
fn test_read_bytes_overflow() {
let data = [1, 2, 3];
let mut reader = Reader::new(&data);
assert!(matches!(
reader.read_bytes(4),
Err(RatchetError::DeserializationFailed)
));
}
#[test]
fn test_read_array() {
let data = [1, 2, 3, 4];
let mut reader = Reader::new(&data);
let arr: [u8; 4] = reader.read_array().unwrap();
assert_eq!(arr, [1, 2, 3, 4]);
}
#[test]
fn test_read_u8() {
let data = [0x42, 0xFF];
let mut reader = Reader::new(&data);
assert_eq!(reader.read_u8().unwrap(), 0x42);
assert_eq!(reader.read_u8().unwrap(), 0xFF);
}
#[test]
fn test_read_u32() {
let data = [0x00, 0x01, 0x02, 0x03];
let mut reader = Reader::new(&data);
assert_eq!(reader.read_u32().unwrap(), 0x00010203);
}
#[test]
fn test_read_option_none() {
let data = [0x00];
let mut reader = Reader::new(&data);
assert_eq!(reader.read_option().unwrap(), None);
}
#[test]
fn test_read_option_some() {
let mut data = vec![0x01];
data.extend_from_slice(&[0x42; 32]);
let mut reader = Reader::new(&data);
assert_eq!(reader.read_option().unwrap(), Some([0x42; 32]));
}
#[test]
fn test_read_option_invalid_flag() {
let data = [0x02];
let mut reader = Reader::new(&data);
assert!(matches!(
reader.read_option(),
Err(RatchetError::DeserializationFailed)
));
}
#[test]
fn test_sequential_reads() {
let mut data = vec![0x01]; // version
data.extend_from_slice(&[0xAA; 32]); // 32-byte array
data.extend_from_slice(&[0x00, 0x00, 0x00, 0x10]); // u32 = 16
let mut reader = Reader::new(&data);
assert_eq!(reader.read_u8().unwrap(), 0x01);
assert_eq!(reader.read_array::<32>().unwrap(), [0xAA; 32]);
assert_eq!(reader.read_u32().unwrap(), 16);
}
}

View File

@ -1,15 +1,21 @@
use std::{collections::HashMap, marker::PhantomData};
use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as DeError};
use x25519_dalek::PublicKey;
use zeroize::{Zeroize, Zeroizing};
use crate::{
aead::{decrypt, encrypt},
errors::RatchetError,
hkdf::{DefaultDomain, HkdfInfo, kdf_chain, kdf_root},
keypair::InstallationKeyPair,
reader::Reader,
types::{ChainKey, MessageKey, Nonce, RootKey, SharedSecret},
};
/// Current binary format version.
const SERIALIZATION_VERSION: u8 = 1;
/// Represents the local state of the Double Ratchet algorithm for one conversation.
///
/// This struct maintains all keys and counters required to perform the Double Ratchet
@ -42,6 +48,153 @@ pub struct SkippedKey {
pub message_key: MessageKey,
}
impl<D: HkdfInfo> RatchetState<D> {
/// Serializes the ratchet state to a binary format.
///
/// # Binary Format (Version 1)
///
/// ```text
/// | Field | Size (bytes) | Description |
/// |--------------------|--------------|--------------------------------------|
/// | version | 1 | Format version (0x01) |
/// | root_key | 32 | Root key |
/// | sending_chain_flag | 1 | 0x00 = None, 0x01 = Some |
/// | sending_chain | 0 or 32 | Chain key if flag is 0x01 |
/// | receiving_chain_flag| 1 | 0x00 = None, 0x01 = Some |
/// | receiving_chain | 0 or 32 | Chain key if flag is 0x01 |
/// | dh_self_secret | 32 | DH secret key |
/// | dh_remote_flag | 1 | 0x00 = None, 0x01 = Some |
/// | dh_remote | 0 or 32 | DH public key if flag is 0x01 |
/// | msg_send | 4 | Send counter (big-endian) |
/// | msg_recv | 4 | Receive counter (big-endian) |
/// | prev_chain_len | 4 | Previous chain length (big-endian) |
/// | skipped_count | 4 | Number of skipped keys (big-endian) |
/// | skipped_keys | 68 * count | Each: pubkey(32) + msg_num(4) + key(32) |
/// ```
pub fn as_bytes(&self) -> Zeroizing<Vec<u8>> {
fn option_size(opt: Option<[u8; 32]>) -> usize {
1 + opt.map_or(0, |_| 32)
}
fn write_option(buf: &mut Vec<u8>, opt: Option<[u8; 32]>) {
match opt {
Some(data) => {
buf.push(0x01);
buf.extend_from_slice(&data);
}
None => buf.push(0x00),
}
}
let skipped_count = self.skipped_keys.len();
let dh_remote = self.dh_remote.map(|pk| pk.to_bytes());
let capacity = 1 + 32 // version + root_key
+ option_size(self.sending_chain)
+ option_size(self.receiving_chain)
+ 32 // dh_self
+ option_size(dh_remote)
+ 12 // counters
+ 4 + (skipped_count * 68); // skipped keys
let mut buf = Zeroizing::new(Vec::with_capacity(capacity));
buf.push(SERIALIZATION_VERSION);
buf.extend_from_slice(&self.root_key);
write_option(&mut buf, self.sending_chain);
write_option(&mut buf, self.receiving_chain);
let dh_secret = self.dh_self.secret_bytes();
buf.extend_from_slice(dh_secret);
write_option(&mut buf, dh_remote);
buf.extend_from_slice(&self.msg_send.to_be_bytes());
buf.extend_from_slice(&self.msg_recv.to_be_bytes());
buf.extend_from_slice(&self.prev_chain_len.to_be_bytes());
buf.extend_from_slice(&(skipped_count as u32).to_be_bytes());
for ((pk, msg_num), mk) in &self.skipped_keys {
buf.extend_from_slice(pk.as_bytes());
buf.extend_from_slice(&msg_num.to_be_bytes());
buf.extend_from_slice(mk);
}
buf
}
/// Deserializes a ratchet state from binary data.
///
/// # Errors
///
/// Returns `RatchetError::DeserializationFailed` if the data is invalid or truncated.
pub fn from_bytes(data: &[u8]) -> Result<Self, RatchetError> {
let mut reader = Reader::new(data);
let version = reader.read_u8()?;
if version != SERIALIZATION_VERSION {
return Err(RatchetError::DeserializationFailed);
}
let root_key: RootKey = reader.read_array()?;
let sending_chain = reader.read_option()?;
let receiving_chain = reader.read_option()?;
let mut dh_self_bytes: [u8; 32] = reader.read_array()?;
let dh_self = InstallationKeyPair::from_secret_bytes(dh_self_bytes);
dh_self_bytes.zeroize();
let dh_remote = reader.read_option()?.map(PublicKey::from);
let msg_send = reader.read_u32()?;
let msg_recv = reader.read_u32()?;
let prev_chain_len = reader.read_u32()?;
let skipped_count = reader.read_u32()? as usize;
let mut skipped_keys = HashMap::with_capacity(skipped_count);
for _ in 0..skipped_count {
let pk = PublicKey::from(reader.read_array::<32>()?);
let msg_num = reader.read_u32()?;
let mk: MessageKey = reader.read_array()?;
skipped_keys.insert((pk, msg_num), mk);
}
Ok(Self {
root_key,
sending_chain,
receiving_chain,
dh_self,
dh_remote,
msg_send,
msg_recv,
prev_chain_len,
skipped_keys,
_domain: PhantomData,
})
}
}
/// Custom serde Serialize implementation that uses our binary format.
impl<D: HkdfInfo> Serialize for RatchetState<D> {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_bytes(&self.as_bytes())
}
}
/// Custom serde Deserialize implementation that uses our binary format.
impl<'de, D: HkdfInfo> Deserialize<'de> for RatchetState<D> {
fn deserialize<De>(deserializer: De) -> Result<Self, De::Error>
where
De: Deserializer<'de>,
{
let bytes = <Vec<u8>>::deserialize(deserializer)?;
Self::from_bytes(&bytes).map_err(DeError::custom)
}
}
/// Public header attached to every encrypted message (unencrypted but authenticated).
#[derive(Clone, Debug)]
pub struct Header {
@ -513,6 +666,156 @@ mod tests {
assert_eq!(result.unwrap_err(), RatchetError::MessageReplay);
}
#[test]
fn test_serialize_deserialize_sender_state() {
let (alice, _, _) = setup_alice_bob();
// Serialize to binary
let bytes = alice.as_bytes();
// Deserialize back
let restored: RatchetState = RatchetState::from_bytes(&bytes).unwrap();
// Verify key fields match
assert_eq!(alice.root_key, restored.root_key);
assert_eq!(alice.sending_chain, restored.sending_chain);
assert_eq!(alice.receiving_chain, restored.receiving_chain);
assert_eq!(alice.msg_send, restored.msg_send);
assert_eq!(alice.msg_recv, restored.msg_recv);
assert_eq!(alice.prev_chain_len, restored.prev_chain_len);
assert_eq!(
alice.dh_remote.map(|pk| pk.to_bytes()),
restored.dh_remote.map(|pk| pk.to_bytes())
);
assert_eq!(
alice.dh_self.public().to_bytes(),
restored.dh_self.public().to_bytes()
);
}
#[test]
fn test_serialize_deserialize_receiver_state() {
let (_, bob, _) = setup_alice_bob();
// Serialize to binary
let bytes = bob.as_bytes();
// Deserialize back
let restored: RatchetState = RatchetState::from_bytes(&bytes).unwrap();
// Verify key fields match
assert_eq!(bob.root_key, restored.root_key);
assert_eq!(bob.sending_chain, restored.sending_chain);
assert_eq!(bob.receiving_chain, restored.receiving_chain);
assert_eq!(bob.msg_send, restored.msg_send);
assert_eq!(bob.msg_recv, restored.msg_recv);
assert_eq!(bob.prev_chain_len, restored.prev_chain_len);
assert!(bob.dh_remote.is_none());
assert!(restored.dh_remote.is_none());
}
#[test]
fn test_serialize_deserialize_with_skipped_keys() {
let (mut alice, mut bob, _) = setup_alice_bob();
// Alice sends 3 messages
let mut sent = vec![];
for i in 0..3 {
let plaintext = format!("Message {}", i + 1).into_bytes();
let (ct, header) = alice.encrypt_message(&plaintext);
sent.push((ct, header, plaintext));
}
// Bob receives only msg0 and msg2, skipping msg1
bob.decrypt_message(&sent[0].0, sent[0].1.clone()).unwrap();
bob.decrypt_message(&sent[2].0, sent[2].1.clone()).unwrap();
// Bob should have one skipped key
assert_eq!(bob.skipped_keys.len(), 1);
// Serialize Bob's state
let bytes = bob.as_bytes();
// Deserialize
let mut restored: RatchetState = RatchetState::from_bytes(&bytes).unwrap();
// Restored state should have the skipped key
assert_eq!(restored.skipped_keys.len(), 1);
// The restored state should be able to decrypt the skipped message
let pt1 = restored
.decrypt_message(&sent[1].0, sent[1].1.clone())
.unwrap();
assert_eq!(pt1, sent[1].2);
}
#[test]
fn test_serialize_deserialize_continue_conversation() {
let (mut alice, mut bob, _) = setup_alice_bob();
// Exchange some messages
let (ct1, h1) = alice.encrypt_message(b"Hello Bob");
bob.decrypt_message(&ct1, h1).unwrap();
let (ct2, h2) = bob.encrypt_message(b"Hello Alice");
alice.decrypt_message(&ct2, h2).unwrap();
// Serialize both states
let alice_bytes = alice.as_bytes();
let bob_bytes = bob.as_bytes();
// Deserialize
let mut alice_restored: RatchetState = RatchetState::from_bytes(&alice_bytes).unwrap();
let mut bob_restored: RatchetState = RatchetState::from_bytes(&bob_bytes).unwrap();
// Continue the conversation with restored states
let (ct3, h3) = alice_restored.encrypt_message(b"Message after restore");
let pt3 = bob_restored.decrypt_message(&ct3, h3).unwrap();
assert_eq!(pt3, b"Message after restore");
let (ct4, h4) = bob_restored.encrypt_message(b"Reply after restore");
let pt4 = alice_restored.decrypt_message(&ct4, h4).unwrap();
assert_eq!(pt4, b"Reply after restore");
}
#[test]
fn test_serialization_version_check() {
let (alice, _, _) = setup_alice_bob();
let mut bytes = alice.as_bytes();
// Tamper with version byte
bytes[0] = 0xFF;
let result = RatchetState::<DefaultDomain>::from_bytes(&bytes);
assert!(matches!(result, Err(RatchetError::DeserializationFailed)));
}
#[test]
fn test_serialization_truncated_data() {
let (alice, _, _) = setup_alice_bob();
let bytes = alice.as_bytes();
// Truncate the data
let truncated = &bytes[..10];
let result = RatchetState::<DefaultDomain>::from_bytes(truncated);
assert!(matches!(result, Err(RatchetError::DeserializationFailed)));
}
#[test]
fn test_serialization_size_efficiency() {
let (alice, _, _) = setup_alice_bob();
let bytes = alice.as_bytes();
// Minimum size: version(1) + root_key(32) + sending_flag(1) + sending(32) +
// receiving_flag(1) + dh_self(32) + dh_remote_flag(1) + dh_remote(32) +
// counters(12) + skipped_count(4) = 148 bytes for sender with no skipped keys
assert!(bytes.len() < 200, "Serialized size should be compact");
// Verify version byte
assert_eq!(bytes[0], 1, "Version should be 1");
}
#[test]
fn test_skipped_keys_export() {
let (mut alice, mut bob, _) = setup_alice_bob();

View File

@ -26,7 +26,7 @@ impl<D: HkdfInfo> From<&RatchetState<D>> for RatchetStateRecord {
root_key: state.root_key,
sending_chain: state.sending_chain,
receiving_chain: state.receiving_chain,
dh_self_secret: state.dh_self.secret_bytes(),
dh_self_secret: *state.dh_self.secret_bytes(),
dh_remote: state.dh_remote.map(|pk| pk.to_bytes()),
msg_send: state.msg_send,
msg_recv: state.msg_recv,

8
nim-bindings/README.md Normal file
View File

@ -0,0 +1,8 @@
# Nim-bindings
A Nim wrapping class that exposes LibChat functionality.
## Getting Started
`nimble pingpong` - Run the pingpong example.

View File

@ -0,0 +1,21 @@
# Package
version = "0.1.0"
author = "libchat"
description = "Nim Bindings for LibChat"
license = "MIT"
srcDir = "src"
bin = @["libchat"]
# Dependencies
requires "nim >= 2.2.4"
requires "results"
# Build Rust library before compiling Nim
before build:
exec "cargo build --release --manifest-path ../Cargo.toml"
task pingpong, "Run pingpong example":
exec "nim c -r --path:src examples/pingpong.nim"

View File

@ -0,0 +1,35 @@
import options
import results
import ../src/libchat
proc pingpong() =
var raya = newConversationsContext()
var saro = newConversationsContext()
# Perform out of band Introduction
let intro = raya.createIntroductionBundle().expect("[Raya] Couldn't create intro bundle")
echo "Raya's Intro Bundle: ",intro
var (convo_sr, payloads) = saro.createNewPrivateConvo(intro, "Hey Raya").expect("[Saro] Couldn't create convo")
echo "ConvoHandle:: ", convo_sr
echo "Payload:: ", payloads
## Send Payloads to Raya
for p in payloads:
let res = raya.handlePayload(p.data)
if res.isOk:
let opt = res.get()
if opt.isSome:
let content_result = opt.get()
echo "RecvContent: ", content_result.conversationId, " ", content_result.data
else:
echo "Failed to handle payload: ", res.error
echo "Done"
when isMainModule:
pingpong()

View File

@ -0,0 +1,175 @@
# Nim FFI bindings for libchat conversations library
import std/[os]
# Dynamic library path resolution
# Can be overridden at compile time with -d:CONVERSATIONS_LIB:"path/to/lib"
# Or at runtime via LIBCHAT_LIB environment variable
when defined(macosx):
const DEFAULT_LIB_NAME = "liblogos_chat.dylib"
elif defined(linux):
const DEFAULT_LIB_NAME = "liblogos_chat.so"
elif defined(windows):
const DEFAULT_LIB_NAME = "logos_chat.dll"
else:
const DEFAULT_LIB_NAME = "logos_chat"
# Try to find the library relative to the source file location at compile time
const
thisDir = currentSourcePath().parentDir()
projectRoot = thisDir.parentDir().parentDir()
releaseLibPath = projectRoot / "target" / "release" / DEFAULT_LIB_NAME
debugLibPath = projectRoot / "target" / "debug" / DEFAULT_LIB_NAME
# Default to release path, can be overridden with -d:CONVERSATIONS_LIB:"..."
const CONVERSATIONS_LIB* {.strdefine.} = releaseLibPath
# Error codes (must match Rust ErrorCode enum)
const
ErrNone* = 0'i32
ErrBadPtr* = -1'i32
ErrBadConvoId* = -2'i32
ErrBadIntro* = -3'i32
ErrNotImplemented* = -4'i32
ErrBufferExceeded* = -5'i32
ErrUnknownError* = -6'i32
# Opaque handle type for Context
type ContextHandle* = pointer
type ConvoHandle* = uint32
type
## Slice for passing byte arrays to safer_ffi functions
SliceUint8* = object
`ptr`*: ptr uint8
len*: csize_t
## Vector type returned by safer_ffi functions (must be freed)
VecUint8* = object
`ptr`*: ptr uint8
len*: csize_t
cap*: csize_t
## repr_c::String type from safer_ffi
ReprCString* = object
`ptr`*: ptr char
len*: csize_t
cap*: csize_t
## Payload structure for FFI (matches Rust Payload struct)
Payload* = object
address*: ReprCString
data*: VecUint8
## Vector of Payloads returned by safer_ffi functions
VecPayload* = object
`ptr`*: ptr Payload
len*: csize_t
cap*: csize_t
## Result structure for create_intro_bundle
## error_code is 0 on success, negative on error (see ErrorCode)
PayloadResult* = object
error_code*: int32
payloads*: VecPayload
## Result from create_new_private_convo
## error_code is 0 on success, negative on error (see ErrorCode)
NewConvoResult* = object
error_code*: int32
convo_id*: uint32
payloads*: VecPayload
# FFI function imports
## Creates a new libchat Context
## Returns: Opaque handle to the context. Must be freed with destroy_context()
proc create_context*(): ContextHandle {.importc, dynlib: CONVERSATIONS_LIB.}
## Destroys a context and frees its memory
## - handle must be a valid pointer from create_context()
## - handle must not be used after this call
proc destroy_context*(ctx: ContextHandle) {.importc, dynlib: CONVERSATIONS_LIB.}
## Creates an intro bundle for sharing with other users
## Returns: Number of bytes written to bundle_out, or negative error code
proc create_intro_bundle*(
ctx: ContextHandle,
bundle_out: SliceUint8,
): int32 {.importc, dynlib: CONVERSATIONS_LIB.}
## Creates a new private conversation
## Returns: NewConvoResult struct - check error_code field (0 = success, negative = error)
## The result must be freed with destroy_convo_result()
proc create_new_private_convo*(
ctx: ContextHandle,
bundle: SliceUint8,
content: SliceUint8,
): NewConvoResult {.importc, dynlib: CONVERSATIONS_LIB.}
## Sends content to an existing conversation
## Returns: PayloadResult struct - check error_code field (0 = success, negative = error)
## The result must be freed with destroy_payload_result()
proc send_content*(
ctx: ContextHandle,
convo_handle: ConvoHandle,
content: SliceUint8,
): PayloadResult {.importc, dynlib: CONVERSATIONS_LIB.}
## Handles an incoming payload and writes content to caller-provided buffers
## Returns: Number of bytes written to content_out on success (>= 0), negative error code on failure
## conversation_id_out_len is set to the number of bytes written to conversation_id_out
proc handle_payload*(
ctx: ContextHandle,
payload: SliceUint8,
conversation_id_out: SliceUint8,
conversation_id_out_len: ptr uint32,
content_out: SliceUint8,
): int32 {.importc, dynlib: CONVERSATIONS_LIB.}
## Free the result from create_new_private_convo
proc destroy_convo_result*(result: NewConvoResult) {.importc, dynlib: CONVERSATIONS_LIB.}
## Free the PayloadResult
proc destroy_payload_result*(result: PayloadResult) {.importc, dynlib: CONVERSATIONS_LIB.}
# ============================================================================
# Helper functions
# ============================================================================
## Create a SliceRefUint8 from a string
proc toSlice*(s: string): SliceUint8 =
if s.len == 0:
SliceUint8(`ptr`: nil, len: 0)
else:
SliceUint8(`ptr`: cast[ptr uint8](unsafeAddr s[0]), len: csize_t(s.len))
## Create a SliceRefUint8 from a seq[byte]
proc toSlice*(s: seq[byte]): SliceUint8 =
if s.len == 0:
SliceUint8(`ptr`: nil, len: 0)
else:
SliceUint8(`ptr`: cast[ptr uint8](unsafeAddr s[0]), len: csize_t(s.len))
## Convert a ReprCString to a Nim string
proc `$`*(s: ReprCString): string =
if s.ptr == nil or s.len == 0:
return ""
result = newString(s.len)
copyMem(addr result[0], s.ptr, s.len)
## Convert a VecUint8 to a seq[byte]
proc toSeq*(v: VecUint8): seq[byte] =
if v.ptr == nil or v.len == 0:
return @[]
result = newSeq[byte](v.len)
copyMem(addr result[0], v.ptr, v.len)
## Access payloads from VecPayload
proc `[]`*(v: VecPayload, i: int): Payload =
assert i >= 0 and csize_t(i) < v.len
cast[ptr UncheckedArray[Payload]](v.ptr)[i]
## Get length of VecPayload
proc len*(v: VecPayload): int =
int(v.len)

View File

@ -0,0 +1,159 @@
import std/options
import results
import bindings
type
LibChat* = object
handle: ContextHandle
buffer_size: int
PayloadResult* = object
address*: string
data*: seq[uint8]
## Create a new conversations context
proc newConversationsContext*(): LibChat =
result.handle = create_context()
result.buffer_size = 256
if result.handle.isNil:
raise newException(IOError, "Failed to create context")
## Destroy the context and free resources
proc destroy*(ctx: var LibChat) =
if not ctx.handle.isNil:
destroy_context(ctx.handle)
ctx.handle = nil
## Helper proc to create buffer of sufficient size
proc getBuffer*(ctx: LibChat): seq[byte] =
newSeq[byte](ctx.buffer_size)
## Generate a Introduction Bundle
proc createIntroductionBundle*(ctx: LibChat): Result[string, string] =
if ctx.handle == nil:
return err("Context handle is nil")
var buffer = ctx.getBuffer()
var slice = buffer.toSlice()
let len = create_intro_bundle(ctx.handle, slice)
if len < 0:
return err("Failed to create intro bundle: " & $len)
buffer.setLen(len)
return ok(cast[string](buffer))
## Create a Private Convo
proc createNewPrivateConvo*(ctx: LibChat, bundle: string, content: string): Result[(ConvoHandle, seq[PayloadResult]), string] =
if ctx.handle == nil:
return err("Context handle is nil")
if bundle.len == 0:
return err("bundle is zero length")
if content.len == 0:
return err("content is zero length")
let res = bindings.create_new_private_convo(
ctx.handle,
bundle.toSlice(),
content.toSlice()
)
if res.error_code != 0:
result = err("Failed to create private convo: " & $res.error_code)
destroy_convo_result(res)
return
# Convert payloads to Nim types
var payloads = newSeq[PayloadResult](res.payloads.len)
for i in 0 ..< res.payloads.len:
let p = res.payloads[int(i)]
payloads[int(i)] = PayloadResult(
address: $p.address,
data: p.data.toSeq()
)
let convoId = res.convo_id
# Free the result
destroy_convo_result(res)
return ok((convoId, payloads))
## Send content to an existing conversation
proc sendContent*(ctx: LibChat, convoHandle: ConvoHandle, content: string): Result[seq[PayloadResult], string] =
if ctx.handle == nil:
return err("Context handle is nil")
if content.len == 0:
return err("content is zero length")
let res = bindings.send_content(
ctx.handle,
convoHandle,
content.toSlice()
)
if res.error_code != 0:
result = err("Failed to send content: " & $res.error_code)
destroy_payload_result(res)
return
# Convert payloads to Nim types
var payloads = newSeq[PayloadResult](res.payloads.len)
for i in 0 ..< res.payloads.len:
let p = res.payloads[int(i)]
payloads[int(i)] = PayloadResult(
address: $p.address,
data: p.data.toSeq()
)
destroy_payload_result(res)
return ok(payloads)
type
ContentResult* = object
conversationId*: string
data*: seq[uint8]
## Handle an incoming payload and decrypt content
proc handlePayload*(ctx: LibChat, payload: seq[byte]): Result[Option[ContentResult], string] =
if ctx.handle == nil:
return err("Context handle is nil")
if payload.len == 0:
return err("payload is zero length")
var conversationIdBuf = newSeq[byte](ctx.buffer_size)
var contentBuf = newSeq[byte](ctx.buffer_size)
var conversationIdLen: uint32 = 0
let bytesWritten = bindings.handle_payload(
ctx.handle,
payload.toSlice(),
conversationIdBuf.toSlice(),
addr conversationIdLen,
contentBuf.toSlice()
)
if bytesWritten < 0:
return err("Failed to handle payload: " & $bytesWritten)
if bytesWritten == 0:
return ok(none(ContentResult))
conversationIdBuf.setLen(conversationIdLen)
contentBuf.setLen(bytesWritten)
return ok(some(ContentResult(
conversationId: cast[string](conversationIdBuf),
data: contentBuf
)))
proc `=destroy`(x: var LibChat) =
# Automatically free handle when the destructor is called
if x.handle != nil:
x.destroy()