mirror of
https://github.com/logos-messaging/libchat.git
synced 2026-03-30 08:03:09 +00:00
Merge branch 'main' of github.com:logos-messaging/libchat into storage-crate
This commit is contained in:
commit
5626dad62a
3
.gitignore
vendored
3
.gitignore
vendored
@ -24,6 +24,9 @@ target
|
|||||||
|
|
||||||
# Compiled binary
|
# Compiled binary
|
||||||
**/ffi_nim_example
|
**/ffi_nim_example
|
||||||
|
/nim-bindings/examples/pingpong
|
||||||
|
/nim-bindings/libchat
|
||||||
|
|
||||||
# Temporary data folder
|
# Temporary data folder
|
||||||
tmp
|
tmp
|
||||||
|
|
||||||
|
|||||||
14
Cargo.lock
generated
14
Cargo.lock
generated
@ -222,6 +222,7 @@ dependencies = [
|
|||||||
"rand",
|
"rand",
|
||||||
"rand_core",
|
"rand_core",
|
||||||
"safer-ffi",
|
"safer-ffi",
|
||||||
|
"serde",
|
||||||
"storage",
|
"storage",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"x25519-dalek",
|
"x25519-dalek",
|
||||||
@ -473,6 +474,7 @@ dependencies = [
|
|||||||
"hex",
|
"hex",
|
||||||
"prost",
|
"prost",
|
||||||
"rand_core",
|
"rand_core",
|
||||||
|
"safer-ffi",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"x25519-dalek",
|
"x25519-dalek",
|
||||||
]
|
]
|
||||||
@ -507,9 +509,9 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openssl-src"
|
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"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a507b3792995dae9b0df8a1c1e3771e8418b7c2d9f0baeba32e6fe8b06c7cb72"
|
checksum = "3f1787d533e03597a7934fd0a765f0d28e94ecc5fb7789f8053b1e699a56f709"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cc",
|
"cc",
|
||||||
]
|
]
|
||||||
@ -1045,18 +1047,18 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerocopy"
|
name = "zerocopy"
|
||||||
version = "0.8.34"
|
version = "0.8.35"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "71ddd76bcebeed25db614f82bf31a9f4222d3fbba300e6fb6c00afa26cbd4d9d"
|
checksum = "fdea86ddd5568519879b8187e1cf04e24fce28f7fe046ceecbce472ff19a2572"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"zerocopy-derive",
|
"zerocopy-derive",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerocopy-derive"
|
name = "zerocopy-derive"
|
||||||
version = "0.8.34"
|
version = "0.8.35"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d8187381b52e32220d50b255276aa16a084ec0a9017a0ca2152a1f55c539758d"
|
checksum = "0c15e1b46eff7c6c91195752e0eeed8ef040e391cdece7c25376957d5f15df22"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
|
|||||||
@ -4,7 +4,7 @@ version = "0.1.0"
|
|||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
crate-type = ["staticlib"]
|
crate-type = ["staticlib","dylib"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
blake2.workspace = true
|
blake2.workspace = true
|
||||||
@ -13,5 +13,6 @@ crypto = { path = "../crypto" }
|
|||||||
hex = "0.4.3"
|
hex = "0.4.3"
|
||||||
prost = "0.14.1"
|
prost = "0.14.1"
|
||||||
rand_core = { version = "0.6" }
|
rand_core = { version = "0.6" }
|
||||||
|
safer-ffi = "0.1.13"
|
||||||
thiserror = "2.0.17"
|
thiserror = "2.0.17"
|
||||||
x25519-dalek = { version = "2.0.1", features = ["static_secrets", "reusable_secrets", "getrandom"] }
|
x25519-dalek = { version = "2.0.1", features = ["static_secrets", "reusable_secrets", "getrandom"] }
|
||||||
|
|||||||
@ -1,25 +1,31 @@
|
|||||||
use core::ffi::c_char;
|
use safer_ffi::prelude::*;
|
||||||
use std::{ffi::CStr, slice};
|
|
||||||
|
|
||||||
// Must only contain negative values, values cannot be changed once set.
|
// Must only contain negative values, values cannot be changed once set.
|
||||||
#[repr(i32)]
|
#[repr(i32)]
|
||||||
pub enum ErrorCode {
|
pub enum ErrorCode {
|
||||||
|
None = 0,
|
||||||
BadPtr = -1,
|
BadPtr = -1,
|
||||||
BadConvoId = -2,
|
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
|
/// Creates a new libchat Ctx
|
||||||
///
|
///
|
||||||
/// # Returns
|
/// # Returns
|
||||||
/// Opaque handle to the store. Must be freed with conversation_store_destroy()
|
/// Opaque handle to the store. Must be freed with destroy_context()
|
||||||
#[unsafe(no_mangle)]
|
#[ffi_export]
|
||||||
pub extern "C" fn create_context() -> ContextHandle {
|
pub fn create_context() -> repr_c::Box<ContextHandle> {
|
||||||
let store = Box::new(Context::new());
|
Box::new(ContextHandle(Context::new())).into()
|
||||||
Box::into_raw(store) // Leak the box, return raw pointer
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Destroys a conversation store and frees its memory
|
/// 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 be a valid pointer from conversation_store_create()
|
||||||
/// - handle must not be used after this call
|
/// - handle must not be used after this call
|
||||||
/// - handle must not be freed twice
|
/// - handle must not be freed twice
|
||||||
#[unsafe(no_mangle)]
|
#[ffi_export]
|
||||||
pub unsafe extern "C" fn destroy_context(handle: ContextHandle) {
|
pub fn destroy_context(ctx: repr_c::Box<ContextHandle>) {
|
||||||
if !handle.is_null() {
|
drop(ctx);
|
||||||
unsafe {
|
|
||||||
let _ = Box::from_raw(handle); // Reconstruct box and drop it
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Encrypts/encodes content into payloads.
|
/// Creates an intro bundle for sharing with other users
|
||||||
/// There may be multiple payloads generated from a single content.
|
|
||||||
///
|
///
|
||||||
/// # Returns
|
/// # Returns
|
||||||
/// Returns the number of payloads created.
|
/// Returns the number of bytes written to bundle_out
|
||||||
///
|
/// Check error_code field: 0 means success, negative values indicate errors (see ErrorCode).
|
||||||
/// # Errors
|
#[ffi_export]
|
||||||
/// Negative numbers symbolize an error has occured. See `ErrorCode`
|
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 {
|
||||||
#[unsafe(no_mangle)]
|
return ErrorCode::UnknownError as i32;
|
||||||
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,
|
|
||||||
|
|
||||||
max_payload_count: usize,
|
// Check buffer is large enough
|
||||||
// Output: Addresses
|
if bundle_out.len() < bundle.len() {
|
||||||
addrs: *const *mut c_char,
|
return ErrorCode::BufferExceeded as i32;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
unsafe {
|
bundle_out[..bundle.len()].copy_from_slice(&bundle);
|
||||||
let ctx = &mut *handle;
|
bundle.len() as i32
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Decrypts/decodes payloads into content.
|
/// Creates a new private conversation
|
||||||
/// A payload may return 1 or 0 contents.
|
|
||||||
///
|
///
|
||||||
/// # Returns
|
/// # Returns
|
||||||
/// Returns the number of bytes written to content
|
/// Returns a struct with payloads that must be sent, the conversation_id that was created.
|
||||||
///
|
/// The NewConvoResult must be freed.
|
||||||
/// # Errors
|
#[ffi_export]
|
||||||
/// Negative numbers symbolize an error has occured. See `ErrorCode`
|
pub fn create_new_private_convo(
|
||||||
///
|
ctx: &mut ContextHandle,
|
||||||
#[unsafe(no_mangle)]
|
bundle: c_slice::Ref<'_, u8>,
|
||||||
pub unsafe extern "C" fn handle_payload(
|
content: c_slice::Ref<'_, u8>,
|
||||||
// Input: Context handle
|
) -> NewConvoResult {
|
||||||
handle: ContextHandle,
|
// Convert input bundle to Introduction
|
||||||
// Input: Payload data
|
let Ok(intro) = Introduction::try_from(bundle.as_slice()) else {
|
||||||
payload_data: *const u8,
|
return NewConvoResult {
|
||||||
payload_len: usize,
|
error_code: ErrorCode::BadIntro as i32,
|
||||||
|
convo_id: 0,
|
||||||
|
payloads: Vec::new().into(),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
// Output: Content
|
// Convert input content to String
|
||||||
content: *mut u8,
|
let msg = String::from_utf8_lossy(&content).into_owned();
|
||||||
content_max_len: usize,
|
|
||||||
) -> i32 {
|
|
||||||
if handle.is_null() || payload_data.is_null() || content.is_null() {
|
|
||||||
return ErrorCode::BadPtr as i32;
|
|
||||||
}
|
|
||||||
|
|
||||||
unsafe {
|
// Create conversation
|
||||||
let ctx = &mut *handle;
|
let (convo_handle, payloads) = ctx.0.create_private_convo(&intro, msg);
|
||||||
let payload_slice = slice::from_raw_parts(payload_data, payload_len);
|
|
||||||
let content_buf = slice::from_raw_parts_mut(content, content_max_len);
|
|
||||||
|
|
||||||
// Call ctx.handle_payload to decode the payload
|
// Convert payloads to FFI-compatible vector
|
||||||
let contents = ctx.handle_payload(payload_slice);
|
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 {
|
NewConvoResult {
|
||||||
let copy_len = content_data.data.len().min(content_max_len);
|
error_code: 0,
|
||||||
content_buf[..copy_len].copy_from_slice(&content_data.data[..copy_len]);
|
convo_id: convo_handle,
|
||||||
copy_len as i32
|
payloads: ffi_payloads.into(),
|
||||||
} else {
|
|
||||||
0 // No content produced
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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);
|
||||||
|
}
|
||||||
|
|||||||
@ -1,20 +1,30 @@
|
|||||||
use std::{collections::HashMap, rc::Rc, sync::Arc};
|
use std::{collections::HashMap, rc::Rc, sync::Arc};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
conversation::{ConversationId, ConversationIdOwned, ConversationStore},
|
conversation::{ConversationId, ConversationStore, Convo, Id},
|
||||||
|
errors::ChatError,
|
||||||
identity::Identity,
|
identity::Identity,
|
||||||
inbox::Inbox,
|
inbox::Inbox,
|
||||||
proto,
|
|
||||||
types::{ContentData, PayloadData},
|
types::{ContentData, PayloadData},
|
||||||
};
|
};
|
||||||
|
|
||||||
pub use crate::inbox::Introduction;
|
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.
|
// This is the main entry point to the conversations api.
|
||||||
// Ctx manages lifetimes of objects to process and generate payloads.
|
// Ctx manages lifetimes of objects to process and generate payloads.
|
||||||
pub struct Context {
|
pub struct Context {
|
||||||
_identity: Rc<Identity>,
|
_identity: Rc<Identity>,
|
||||||
store: ConversationStore,
|
store: ConversationStore,
|
||||||
inbox: Inbox,
|
inbox: Inbox,
|
||||||
|
buf_size: usize,
|
||||||
|
convo_handle_map: HashMap<u32, Arc<str>>,
|
||||||
|
next_convo_handle: ConvoHandle,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Context {
|
impl Context {
|
||||||
@ -25,27 +35,38 @@ impl Context {
|
|||||||
_identity: identity,
|
_identity: identity,
|
||||||
store: ConversationStore::new(),
|
store: ConversationStore::new(),
|
||||||
inbox,
|
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(
|
pub fn create_private_convo(
|
||||||
&mut self,
|
&mut self,
|
||||||
remote_bundle: &Introduction,
|
remote_bundle: &Introduction,
|
||||||
content: String,
|
content: String,
|
||||||
) -> (ConversationIdOwned, Vec<PayloadData>) {
|
) -> (ConvoHandle, Vec<PayloadData>) {
|
||||||
let (convo, payloads) = self
|
let (convo, payloads) = self
|
||||||
.inbox
|
.inbox
|
||||||
.invite_to_private_convo(remote_bundle, content)
|
.invite_to_private_convo(remote_bundle, content)
|
||||||
.unwrap_or_else(|_| todo!("Log/Surface Error"));
|
.unwrap_or_else(|_| todo!("Log/Surface Error"));
|
||||||
|
|
||||||
let convo_id = self.store.insert_convo(convo);
|
let convo_handle = self.add_convo(convo);
|
||||||
(convo_id, payloads)
|
(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
|
// !TODO Replace Mock
|
||||||
vec![PayloadData {
|
vec![PayloadData {
|
||||||
delivery_address: _convo_id.into(),
|
delivery_address: format!("addr-for-{convo_id}"),
|
||||||
data: vec![40, 30, 20, 10],
|
data: vec![40, 30, 20, 10],
|
||||||
}]
|
}]
|
||||||
}
|
}
|
||||||
@ -57,6 +78,20 @@ impl Context {
|
|||||||
data: vec![1, 2, 3, 4, 5, 6],
|
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)]
|
#[cfg(test)]
|
||||||
|
|||||||
@ -4,6 +4,8 @@ pub use thiserror::Error;
|
|||||||
pub enum ChatError {
|
pub enum ChatError {
|
||||||
#[error("protocol error: {0:?}")]
|
#[error("protocol error: {0:?}")]
|
||||||
Protocol(String),
|
Protocol(String),
|
||||||
|
#[error("protocol error: Got {0:?} expected {1:?}")]
|
||||||
|
ProtocolExpectation(&'static str, String),
|
||||||
#[error("Failed to decode payload: {0}")]
|
#[error("Failed to decode payload: {0}")]
|
||||||
DecodeError(#[from] prost::DecodeError),
|
DecodeError(#[from] prost::DecodeError),
|
||||||
#[error("incorrect bundle value: {0:?}")]
|
#[error("incorrect bundle value: {0:?}")]
|
||||||
|
|||||||
@ -182,9 +182,9 @@ impl Inbox {
|
|||||||
payload: proto::EncryptedPayload,
|
payload: proto::EncryptedPayload,
|
||||||
) -> Result<proto::InboxHandshakeV1, ChatError> {
|
) -> Result<proto::InboxHandshakeV1, ChatError> {
|
||||||
let Some(proto::Encryption::InboxHandshake(handshake)) = payload.encryption else {
|
let Some(proto::Encryption::InboxHandshake(handshake)) = payload.encryption else {
|
||||||
return Err(ChatError::Protocol(
|
let got = format!("{:?}", payload.encryption);
|
||||||
"Expected inboxhandshake encryption".into(),
|
|
||||||
));
|
return Err(ChatError::ProtocolExpectation("inboxhandshake", got));
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(handshake)
|
Ok(handshake)
|
||||||
|
|||||||
@ -31,12 +31,11 @@ impl Into<Vec<u8>> for Introduction {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TryFrom<Vec<u8>> for Introduction {
|
impl TryFrom<&[u8]> for Introduction {
|
||||||
type Error = ChatError;
|
type Error = ChatError;
|
||||||
|
|
||||||
fn try_from(value: Vec<u8>) -> Result<Self, Self::Error> {
|
fn try_from(value: &[u8]) -> Result<Self, Self::Error> {
|
||||||
let str_value =
|
let str_value = String::from_utf8_lossy(value);
|
||||||
String::from_utf8(value).map_err(|_| ChatError::BadParsing("Introduction"))?;
|
|
||||||
let parts: Vec<&str> = str_value.splitn(3, ':').collect();
|
let parts: Vec<&str> = str_value.splitn(3, ':').collect();
|
||||||
|
|
||||||
if parts[0] != "Bundle" {
|
if parts[0] != "Bundle" {
|
||||||
|
|||||||
@ -15,104 +15,79 @@ pub use api::*;
|
|||||||
mod tests {
|
mod tests {
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use std::ffi::CString;
|
use std::str::FromStr;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_ffi() {}
|
fn test_ffi() {}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_process_and_write() {
|
fn test_invite_convo() {
|
||||||
// Create Context
|
let mut ctx = create_context();
|
||||||
let ctx = create_context();
|
let mut bundle = vec![0u8; 200];
|
||||||
|
|
||||||
// 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");
|
|
||||||
|
|
||||||
|
let bundle_len = create_intro_bundle(&mut ctx, (&mut bundle[..]).into());
|
||||||
unsafe {
|
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]
|
#[test]
|
||||||
fn test_process_and_write_null_ptr() {
|
fn test_message_roundtrip() {
|
||||||
use std::ptr;
|
let mut saro = create_context();
|
||||||
// Create Context
|
let mut raya = create_context();
|
||||||
let ctx = create_context();
|
let mut raya_bundle = vec![0u8; 200];
|
||||||
|
|
||||||
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(),
|
|
||||||
)
|
|
||||||
};
|
|
||||||
|
|
||||||
|
let bundle_len = create_intro_bundle(&mut raya, (&mut raya_bundle[..]).into());
|
||||||
unsafe {
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,6 +21,7 @@ blake2 = "0.10.6"
|
|||||||
safer-ffi = "0.1.13"
|
safer-ffi = "0.1.13"
|
||||||
zeroize = "1.8.2"
|
zeroize = "1.8.2"
|
||||||
storage = { workspace = true }
|
storage = { workspace = true }
|
||||||
|
serde = "1.0"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
headers = ["safer-ffi/headers"]
|
headers = ["safer-ffi/headers"]
|
||||||
|
|||||||
75
double-ratchets/examples/serialization_demo.rs
Normal file
75
double-ratchets/examples/serialization_demo.rs
Normal 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())
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -23,4 +23,7 @@ pub enum RatchetError {
|
|||||||
|
|
||||||
#[error("missing receiving chain")]
|
#[error("missing receiving chain")]
|
||||||
MissingReceivingChain,
|
MissingReceivingChain,
|
||||||
|
|
||||||
|
#[error("deserialization failed")]
|
||||||
|
DeserializationFailed,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -25,12 +25,12 @@ impl InstallationKeyPair {
|
|||||||
&self.public
|
&self.public
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Export the secret key as raw bytes for storage.
|
/// Export the secret key as raw bytes for serialization/storage.
|
||||||
pub fn secret_bytes(&self) -> [u8; 32] {
|
pub fn secret_bytes(&self) -> &[u8; 32] {
|
||||||
self.secret.to_bytes()
|
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 {
|
pub fn from_secret_bytes(bytes: [u8; 32]) -> Self {
|
||||||
let secret = StaticSecret::from(bytes);
|
let secret = StaticSecret::from(bytes);
|
||||||
let public = PublicKey::from(&secret);
|
let public = PublicKey::from(&secret);
|
||||||
|
|||||||
@ -3,6 +3,7 @@ pub mod errors;
|
|||||||
pub mod ffi;
|
pub mod ffi;
|
||||||
pub mod hkdf;
|
pub mod hkdf;
|
||||||
pub mod keypair;
|
pub mod keypair;
|
||||||
|
pub mod reader;
|
||||||
pub mod state;
|
pub mod state;
|
||||||
pub mod storage;
|
pub mod storage;
|
||||||
pub mod types;
|
pub mod types;
|
||||||
|
|||||||
135
double-ratchets/src/reader.rs
Normal file
135
double-ratchets/src/reader.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,15 +1,21 @@
|
|||||||
use std::{collections::HashMap, marker::PhantomData};
|
use std::{collections::HashMap, marker::PhantomData};
|
||||||
|
|
||||||
|
use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as DeError};
|
||||||
use x25519_dalek::PublicKey;
|
use x25519_dalek::PublicKey;
|
||||||
|
use zeroize::{Zeroize, Zeroizing};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
aead::{decrypt, encrypt},
|
aead::{decrypt, encrypt},
|
||||||
errors::RatchetError,
|
errors::RatchetError,
|
||||||
hkdf::{DefaultDomain, HkdfInfo, kdf_chain, kdf_root},
|
hkdf::{DefaultDomain, HkdfInfo, kdf_chain, kdf_root},
|
||||||
keypair::InstallationKeyPair,
|
keypair::InstallationKeyPair,
|
||||||
|
reader::Reader,
|
||||||
types::{ChainKey, MessageKey, Nonce, RootKey, SharedSecret},
|
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.
|
/// 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
|
/// This struct maintains all keys and counters required to perform the Double Ratchet
|
||||||
@ -42,6 +48,153 @@ pub struct SkippedKey {
|
|||||||
pub message_key: MessageKey,
|
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).
|
/// Public header attached to every encrypted message (unencrypted but authenticated).
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct Header {
|
pub struct Header {
|
||||||
@ -513,6 +666,156 @@ mod tests {
|
|||||||
assert_eq!(result.unwrap_err(), RatchetError::MessageReplay);
|
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]
|
#[test]
|
||||||
fn test_skipped_keys_export() {
|
fn test_skipped_keys_export() {
|
||||||
let (mut alice, mut bob, _) = setup_alice_bob();
|
let (mut alice, mut bob, _) = setup_alice_bob();
|
||||||
|
|||||||
@ -26,7 +26,7 @@ impl<D: HkdfInfo> From<&RatchetState<D>> for RatchetStateRecord {
|
|||||||
root_key: state.root_key,
|
root_key: state.root_key,
|
||||||
sending_chain: state.sending_chain,
|
sending_chain: state.sending_chain,
|
||||||
receiving_chain: state.receiving_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()),
|
dh_remote: state.dh_remote.map(|pk| pk.to_bytes()),
|
||||||
msg_send: state.msg_send,
|
msg_send: state.msg_send,
|
||||||
msg_recv: state.msg_recv,
|
msg_recv: state.msg_recv,
|
||||||
|
|||||||
8
nim-bindings/README.md
Normal file
8
nim-bindings/README.md
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
# Nim-bindings
|
||||||
|
|
||||||
|
A Nim wrapping class that exposes LibChat functionality.
|
||||||
|
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
`nimble pingpong` - Run the pingpong example.
|
||||||
21
nim-bindings/conversations_example.nimble
Normal file
21
nim-bindings/conversations_example.nimble
Normal 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"
|
||||||
35
nim-bindings/examples/pingpong.nim
Normal file
35
nim-bindings/examples/pingpong.nim
Normal 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()
|
||||||
|
|
||||||
175
nim-bindings/src/bindings.nim
Normal file
175
nim-bindings/src/bindings.nim
Normal 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)
|
||||||
159
nim-bindings/src/libchat.nim
Normal file
159
nim-bindings/src/libchat.nim
Normal 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()
|
||||||
Loading…
x
Reference in New Issue
Block a user