From 15bb395475024542d66f33bc50dc8dc4163e1210 Mon Sep 17 00:00:00 2001 From: Jazz Turner-Baggs <473256+jazzz@users.noreply.github.com> Date: Thu, 29 Jan 2026 00:59:07 +0700 Subject: [PATCH 1/4] Rust API - conversation creation (#24) * Adds final changes for rust side API * Add Safer_ffi impl * Simplify api * Add const handle offset --- Cargo.lock | 1 + conversations/Cargo.toml | 1 + conversations/src/api.rs | 245 ++++++++++++++----------------- conversations/src/context.rs | 45 +++++- conversations/src/errors.rs | 2 + conversations/src/inbox/inbox.rs | 6 +- conversations/src/lib.rs | 109 +++----------- 7 files changed, 175 insertions(+), 234 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1970c57..d4f5b92 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -473,6 +473,7 @@ dependencies = [ "hex", "prost", "rand_core", + "safer-ffi", "thiserror", "x25519-dalek", ] diff --git a/conversations/Cargo.toml b/conversations/Cargo.toml index 678ee3c..2809a58 100644 --- a/conversations/Cargo.toml +++ b/conversations/Cargo.toml @@ -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"] } diff --git a/conversations/src/api.rs b/conversations/src/api.rs index 2e7ed4b..399c45d 100644 --- a/conversations/src/api.rs +++ b/conversations/src/api.rs @@ -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, 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 { + Box::new(ContextHandle(Context::new())).into() } /// Destroys a conversation store and frees its memory @@ -28,147 +34,114 @@ 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) { + 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 s = String::from_utf8_lossy(&bundle).to_string(); + let Ok(intro) = Introduction::try_from(s) 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 = 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(), } } + +// ============================================================================ +// 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, +} + +/// 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, +} + +/// 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, +} + +/// Free the result from create_new_private_convo_safe +#[ffi_export] +pub fn destroy_convo_result(result: NewConvoResult) { + drop(result); +} diff --git a/conversations/src/context.rs b/conversations/src/context.rs index e40f97c..75ced2f 100644 --- a/conversations/src/context.rs +++ b/conversations/src/context.rs @@ -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. +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, store: ConversationStore, inbox: Inbox, + buf_size: usize, + convo_handle_map: HashMap>, + next_convo_handle: ConvoHandle, } impl Context { @@ -25,21 +35,32 @@ 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) { + ) -> (ConvoHandle, Vec) { 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 { @@ -57,6 +78,20 @@ impl Context { data: vec![1, 2, 3, 4, 5, 6], }) } + + pub fn create_intro_bundle(&mut self) -> Result, 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)] diff --git a/conversations/src/errors.rs b/conversations/src/errors.rs index d6c82b6..fc57a8b 100644 --- a/conversations/src/errors.rs +++ b/conversations/src/errors.rs @@ -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:?}")] diff --git a/conversations/src/inbox/inbox.rs b/conversations/src/inbox/inbox.rs index 7853b6d..b9febef 100644 --- a/conversations/src/inbox/inbox.rs +++ b/conversations/src/inbox/inbox.rs @@ -182,9 +182,9 @@ impl Inbox { payload: proto::EncryptedPayload, ) -> Result { 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) diff --git a/conversations/src/lib.rs b/conversations/src/lib.rs index a94ca3e..ed1a271 100644 --- a/conversations/src/lib.rs +++ b/conversations/src/lib.rs @@ -15,104 +15,33 @@ 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 = vec![0; addr_max_len]; - let mut addr_buffer2: Vec = 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 = vec![0; payload_max_len]; - let mut payload2: Vec = 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 = vec![payload_max_len, payload_max_len]; - let mut actual_lengths: Vec = 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); - } - } - - #[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(), - ) - }; - - unsafe { - destroy_context(ctx); + 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 ctx, + bundle.as_slice().into(), + content.as_bytes().into(), + ); + + assert!(result.error_code == 0, "Error: {}", result.error_code); + + println!(" ID:{:?} Payloads:{:?}", result.convo_id, result.payloads); + + destroy_context(ctx); } } From 1cb1ffc9960179298e0d3751587ba25b36f43bb2 Mon Sep 17 00:00:00 2001 From: Jazz Turner-Baggs <473256+jazzz@users.noreply.github.com> Date: Thu, 29 Jan 2026 01:38:08 +0700 Subject: [PATCH 2/4] Nim FFI Wrapper for LibChat (#25) * remove unneeded code * Update comment style * Update Nim side to support safer_ffi * Fix Introduction from impl * Updates --- .gitignore | 3 + conversations/Cargo.toml | 2 +- conversations/src/api.rs | 3 +- conversations/src/inbox/introduction.rs | 7 +- nim-bindings/README.md | 8 ++ nim-bindings/conversations_example.nimble | 21 +++ nim-bindings/examples/pingpong.nim | 22 +++ nim-bindings/src/bindings.nim | 155 ++++++++++++++++++++++ nim-bindings/src/libchat.nim | 88 ++++++++++++ 9 files changed, 302 insertions(+), 7 deletions(-) create mode 100644 nim-bindings/README.md create mode 100644 nim-bindings/conversations_example.nimble create mode 100644 nim-bindings/examples/pingpong.nim create mode 100644 nim-bindings/src/bindings.nim create mode 100644 nim-bindings/src/libchat.nim diff --git a/.gitignore b/.gitignore index 1fca2de..d395ba5 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,9 @@ target # Compiled binary **/ffi_nim_example +/nim-bindings/examples/pingpong +/nim-bindings/libchat # Temporary data folder tmp + diff --git a/conversations/Cargo.toml b/conversations/Cargo.toml index 2809a58..0fd90ed 100644 --- a/conversations/Cargo.toml +++ b/conversations/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2024" [lib] -crate-type = ["staticlib"] +crate-type = ["staticlib","dylib"] [dependencies] blake2.workspace = true diff --git a/conversations/src/api.rs b/conversations/src/api.rs index 399c45d..9fd7c09 100644 --- a/conversations/src/api.rs +++ b/conversations/src/api.rs @@ -71,8 +71,7 @@ pub fn create_new_private_convo( content: c_slice::Ref<'_, u8>, ) -> NewConvoResult { // Convert input bundle to Introduction - let s = String::from_utf8_lossy(&bundle).to_string(); - let Ok(intro) = Introduction::try_from(s) else { + let Ok(intro) = Introduction::try_from(bundle.as_slice()) else { return NewConvoResult { error_code: ErrorCode::BadIntro as i32, convo_id: 0, diff --git a/conversations/src/inbox/introduction.rs b/conversations/src/inbox/introduction.rs index 2966293..0955e11 100644 --- a/conversations/src/inbox/introduction.rs +++ b/conversations/src/inbox/introduction.rs @@ -31,12 +31,11 @@ impl Into> for Introduction { } } -impl TryFrom> for Introduction { +impl TryFrom<&[u8]> for Introduction { type Error = ChatError; - fn try_from(value: Vec) -> Result { - let str_value = - String::from_utf8(value).map_err(|_| ChatError::BadParsing("Introduction"))?; + fn try_from(value: &[u8]) -> Result { + let str_value = String::from_utf8_lossy(value); let parts: Vec<&str> = str_value.splitn(3, ':').collect(); if parts[0] != "Bundle" { diff --git a/nim-bindings/README.md b/nim-bindings/README.md new file mode 100644 index 0000000..b97671b --- /dev/null +++ b/nim-bindings/README.md @@ -0,0 +1,8 @@ +# Nim-bindings + +A Nim wrapping class that exposes LibChat functionality. + + +## Getting Started + +`nimble pingpong` - Run the pingpong example. \ No newline at end of file diff --git a/nim-bindings/conversations_example.nimble b/nim-bindings/conversations_example.nimble new file mode 100644 index 0000000..3670d1a --- /dev/null +++ b/nim-bindings/conversations_example.nimble @@ -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" \ No newline at end of file diff --git a/nim-bindings/examples/pingpong.nim b/nim-bindings/examples/pingpong.nim new file mode 100644 index 0000000..72b43ce --- /dev/null +++ b/nim-bindings/examples/pingpong.nim @@ -0,0 +1,22 @@ +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, payload) = saro.createNewPrivateConvo(intro,"Hey Raya").expect("[Saro] Couldn't create convo") + echo "ConvoHandle:: ", convo_sr + echo "Payload:: ", payload + + + +when isMainModule: + pingpong() diff --git a/nim-bindings/src/bindings.nim b/nim-bindings/src/bindings.nim new file mode 100644 index 0000000..0b3fd26 --- /dev/null +++ b/nim-bindings/src/bindings.nim @@ -0,0 +1,155 @@ +# 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.} + +## 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) diff --git a/nim-bindings/src/libchat.nim b/nim-bindings/src/libchat.nim new file mode 100644 index 0000000..c0f3f6a --- /dev/null +++ b/nim-bindings/src/libchat.nim @@ -0,0 +1,88 @@ +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)) + + +proc `=destroy`(x: var LibChat) = + # Automatically free handle when the destructor is called + if x.handle != nil: + x.destroy() From 1f0354f8e2332f201173cda7afa6bf3cbc58cc57 Mon Sep 17 00:00:00 2001 From: Jazz Turner-Baggs <473256+jazzz@users.noreply.github.com> Date: Thu, 29 Jan 2026 01:50:41 +0700 Subject: [PATCH 3/4] Add FFI functions for send_content and handle_payload (#29) * Add api calls for handle_payload and send_content * Add handle_payload and send_content to FFI --- conversations/src/api.rs | 65 ++++++++++++++++++++++++++- conversations/src/context.rs | 6 +-- conversations/src/lib.rs | 54 +++++++++++++++++++++-- nim-bindings/examples/pingpong.nim | 19 ++++++-- nim-bindings/src/bindings.nim | 20 +++++++++ nim-bindings/src/libchat.nim | 71 ++++++++++++++++++++++++++++++ 6 files changed, 224 insertions(+), 11 deletions(-) diff --git a/conversations/src/api.rs b/conversations/src/api.rs index 9fd7c09..2b127d4 100644 --- a/conversations/src/api.rs +++ b/conversations/src/api.rs @@ -12,7 +12,7 @@ pub enum ErrorCode { UnknownError = -6, } -use crate::context::{Context, Introduction}; +use crate::context::{Context, ConvoHandle, Introduction}; /// Opaque wrapper for Context #[derive_ReprC] @@ -101,6 +101,69 @@ pub fn create_new_private_convo( } } +/// 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 = 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 // =============================================================================================================================== diff --git a/conversations/src/context.rs b/conversations/src/context.rs index 75ced2f..b8e845e 100644 --- a/conversations/src/context.rs +++ b/conversations/src/context.rs @@ -14,7 +14,7 @@ pub use crate::inbox::Introduction; const INITIAL_CONVO_HANDLE: u32 = 0xF5000001; /// Used to identify a conversation on the othersize of the FFI. -type ConvoHandle = u32; +pub type ConvoHandle = u32; // This is the main entry point to the conversations api. // Ctx manages lifetimes of objects to process and generate payloads. @@ -63,10 +63,10 @@ impl Context { (convo_handle, payloads) } - pub fn send_content(&mut self, _convo_id: ConversationId, _content: &[u8]) -> Vec { + pub fn send_content(&mut self, convo_id: ConvoHandle, _content: &[u8]) -> Vec { // !TODO Replace Mock vec![PayloadData { - delivery_address: _convo_id.into(), + delivery_address: format!("addr-for-{convo_id}"), data: vec![40, 30, 20, 10], }] } diff --git a/conversations/src/lib.rs b/conversations/src/lib.rs index ed1a271..91aab32 100644 --- a/conversations/src/lib.rs +++ b/conversations/src/lib.rs @@ -30,18 +30,64 @@ mod tests { 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_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 { + raya_bundle.set_len(bundle_len as usize); + } + assert!(bundle_len > 0, "bundle failed: {}", bundle_len); let content = String::from_str("Hello").unwrap(); let result = create_new_private_convo( - &mut ctx, - bundle.as_slice().into(), + &mut saro, + raya_bundle.as_slice().into(), content.as_bytes().into(), ); assert!(result.error_code == 0, "Error: {}", result.error_code); - println!(" ID:{:?} Payloads:{:?}", result.convo_id, result.payloads); + // 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]; - destroy_context(ctx); + 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); } } diff --git a/nim-bindings/examples/pingpong.nim b/nim-bindings/examples/pingpong.nim index 72b43ce..6ee3d43 100644 --- a/nim-bindings/examples/pingpong.nim +++ b/nim-bindings/examples/pingpong.nim @@ -1,3 +1,4 @@ +import options import results import ../src/libchat @@ -12,11 +13,23 @@ proc pingpong() = let intro = raya.createIntroductionBundle().expect("[Raya] Couldn't create intro bundle") echo "Raya's Intro Bundle: ",intro - var (convo_sr, payload) = saro.createNewPrivateConvo(intro,"Hey Raya").expect("[Saro] Couldn't create convo") + var (convo_sr, payloads) = saro.createNewPrivateConvo(intro, "Hey Raya").expect("[Saro] Couldn't create convo") echo "ConvoHandle:: ", convo_sr - echo "Payload:: ", payload - + 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() + diff --git a/nim-bindings/src/bindings.nim b/nim-bindings/src/bindings.nim index 0b3fd26..3edf2d9 100644 --- a/nim-bindings/src/bindings.nim +++ b/nim-bindings/src/bindings.nim @@ -107,6 +107,26 @@ proc create_new_private_convo*( 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.} diff --git a/nim-bindings/src/libchat.nim b/nim-bindings/src/libchat.nim index c0f3f6a..8203f46 100644 --- a/nim-bindings/src/libchat.nim +++ b/nim-bindings/src/libchat.nim @@ -1,3 +1,4 @@ +import std/options import results import bindings @@ -81,6 +82,76 @@ proc createNewPrivateConvo*(ctx: LibChat, bundle: string, content: string): Resu 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 From 10940321ff362754782bc4ee8406909538ac1f7f Mon Sep 17 00:00:00 2001 From: kaichao Date: Thu, 29 Jan 2026 09:19:52 +0800 Subject: [PATCH 4/4] Encode ratchet sate for serialization (#20) * feat: costom encode for double ratchet * chore: correct capacity * chore: refactor reference * chore: reader for parse bytes * chore: extract reader * chore: example with persist state. * chore: update example * chore: implement serde compatibility. * chore: as_bytes * chore: zerorize the secrec material * chore: use as_types to return reference for static key. * chore: extract example from basic demo --- Cargo.lock | 1 + double-ratchets/Cargo.toml | 1 + .../examples/serialization_demo.rs | 75 +++++ double-ratchets/src/errors.rs | 3 + double-ratchets/src/keypair.rs | 8 +- double-ratchets/src/lib.rs | 1 + double-ratchets/src/reader.rs | 135 ++++++++ double-ratchets/src/state.rs | 303 ++++++++++++++++++ 8 files changed, 523 insertions(+), 4 deletions(-) create mode 100644 double-ratchets/examples/serialization_demo.rs create mode 100644 double-ratchets/src/reader.rs diff --git a/Cargo.lock b/Cargo.lock index d4f5b92..e9850d5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -223,6 +223,7 @@ dependencies = [ "rand_core", "rusqlite", "safer-ffi", + "serde", "thiserror", "x25519-dalek", "zeroize", diff --git a/double-ratchets/Cargo.toml b/double-ratchets/Cargo.toml index 048011d..de78550 100644 --- a/double-ratchets/Cargo.toml +++ b/double-ratchets/Cargo.toml @@ -20,6 +20,7 @@ thiserror = "2" blake2 = "0.10.6" safer-ffi = "0.1.13" zeroize = "1.8.2" +serde = "1.0" rusqlite = { version = "0.35", optional = true, features = ["bundled"] } [features] diff --git a/double-ratchets/examples/serialization_demo.rs b/double-ratchets/examples/serialization_demo.rs new file mode 100644 index 0000000..76a5878 --- /dev/null +++ b/double-ratchets/examples/serialization_demo.rs @@ -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 = + RatchetState::init_sender(shared_secret, bob_dh.public().clone()); + let mut bob: RatchetState = 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 = + RatchetState::from_bytes(&alice_state).unwrap(); + let mut bob_new: RatchetState = 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()) + ); +} diff --git a/double-ratchets/src/errors.rs b/double-ratchets/src/errors.rs index c0e15c7..1787a57 100644 --- a/double-ratchets/src/errors.rs +++ b/double-ratchets/src/errors.rs @@ -23,4 +23,7 @@ pub enum RatchetError { #[error("missing receiving chain")] MissingReceivingChain, + + #[error("deserialization failed")] + DeserializationFailed, } diff --git a/double-ratchets/src/keypair.rs b/double-ratchets/src/keypair.rs index c32adb9..7943646 100644 --- a/double-ratchets/src/keypair.rs +++ b/double-ratchets/src/keypair.rs @@ -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); diff --git a/double-ratchets/src/lib.rs b/double-ratchets/src/lib.rs index 1b9a566..f2cd789 100644 --- a/double-ratchets/src/lib.rs +++ b/double-ratchets/src/lib.rs @@ -3,6 +3,7 @@ pub mod errors; pub mod ffi; pub mod hkdf; pub mod keypair; +pub mod reader; pub mod state; #[cfg(feature = "storage")] pub mod storage; diff --git a/double-ratchets/src/reader.rs b/double-ratchets/src/reader.rs new file mode 100644 index 0000000..bb4b89f --- /dev/null +++ b/double-ratchets/src/reader.rs @@ -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(&mut self) -> Result<[u8; N], RatchetError> { + self.read_bytes(N)? + .try_into() + .map_err(|_| RatchetError::DeserializationFailed) + } + + pub fn read_u8(&mut self) -> Result { + Ok(self.read_bytes(1)?[0]) + } + + pub fn read_u32(&mut self) -> Result { + Ok(u32::from_be_bytes(self.read_array()?)) + } + + pub fn read_option(&mut self) -> Result, 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); + } +} diff --git a/double-ratchets/src/state.rs b/double-ratchets/src/state.rs index dc92ee1..48ad359 100644 --- a/double-ratchets/src/state.rs +++ b/double-ratchets/src/state.rs @@ -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 RatchetState { + /// 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> { + fn option_size(opt: Option<[u8; 32]>) -> usize { + 1 + opt.map_or(0, |_| 32) + } + + fn write_option(buf: &mut Vec, 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 { + 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 Serialize for RatchetState { + fn serialize(&self, serializer: S) -> Result + 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 { + fn deserialize(deserializer: De) -> Result + where + De: Deserializer<'de>, + { + let bytes = >::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::::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::::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();