feat: Sprint 3 — C FFI bindings, WASM compilation, Python example, SDK docs
- Create quicproquo-ffi crate with 7 extern "C" functions: connect, login, send, receive, disconnect, last_error, free_string (produces libquicproquo_ffi.so and .a) - Feature-gate quicproquo-core for WASM: identity, hybrid_kem, safety_numbers, sealed_sender, app_message, padding, transcript all compile to wasm32-unknown-unknown - Add Python ctypes example (examples/python/qpq_client.py) with QpqClient wrapper class and CLI - Add SDK documentation: FFI reference, WASM guide, qpq-gen generators - Update Dockerfile for quicproquo-ffi workspace member
This commit is contained in:
400
crates/quicproquo-ffi/src/lib.rs
Normal file
400
crates/quicproquo-ffi/src/lib.rs
Normal file
@@ -0,0 +1,400 @@
|
||||
#![allow(unsafe_code)]
|
||||
//! quicproquo-ffi -- C FFI bindings for quicproquo messaging operations.
|
||||
//!
|
||||
//! Provides a synchronous C API that wraps the async quicproquo-client library.
|
||||
//! Each `QpqHandle` owns a Tokio runtime; FFI functions use `runtime.block_on()`
|
||||
//! to bridge from synchronous C callers to the async Rust internals.
|
||||
//!
|
||||
//! # Safety
|
||||
//!
|
||||
//! All FFI functions are `unsafe extern "C"` -- callers must ensure pointers
|
||||
//! are valid and strings are null-terminated UTF-8.
|
||||
|
||||
use std::ffi::{CStr, CString, c_char};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use tokio::runtime::Runtime;
|
||||
|
||||
// Status codes returned by FFI functions.
|
||||
pub const QPQ_OK: i32 = 0;
|
||||
pub const QPQ_ERROR: i32 = 1;
|
||||
pub const QPQ_AUTH_FAILED: i32 = 2;
|
||||
pub const QPQ_TIMEOUT: i32 = 3;
|
||||
pub const QPQ_NOT_CONNECTED: i32 = 4;
|
||||
|
||||
/// Opaque handle exposed to C callers via pointer.
|
||||
pub struct QpqHandle {
|
||||
runtime: Runtime,
|
||||
server: String,
|
||||
ca_cert: PathBuf,
|
||||
server_name: String,
|
||||
state_path: PathBuf,
|
||||
state_password: Option<String>,
|
||||
logged_in: bool,
|
||||
last_error: Option<CString>,
|
||||
}
|
||||
|
||||
impl QpqHandle {
|
||||
fn set_error(&mut self, msg: &str) {
|
||||
self.last_error = CString::new(msg).ok();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Convert a `*const c_char` to `&str`, returning `None` on null or invalid UTF-8.
|
||||
unsafe fn cstr_to_str<'a>(ptr: *const c_char) -> Option<&'a str> {
|
||||
if ptr.is_null() {
|
||||
return None;
|
||||
}
|
||||
CStr::from_ptr(ptr).to_str().ok()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FFI functions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Create a new handle and connect to the quicproquo server.
|
||||
///
|
||||
/// Returns a heap-allocated `QpqHandle` pointer on success, or null on failure.
|
||||
///
|
||||
/// # Parameters
|
||||
/// - `server`: server address as `host:port` (null-terminated UTF-8).
|
||||
/// - `ca_cert`: path to the CA certificate file (null-terminated UTF-8).
|
||||
/// - `server_name`: TLS server name (null-terminated UTF-8).
|
||||
///
|
||||
/// # Safety
|
||||
/// All pointer arguments must be valid, non-null, null-terminated C strings.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn qpq_connect(
|
||||
server: *const c_char,
|
||||
ca_cert: *const c_char,
|
||||
server_name: *const c_char,
|
||||
) -> *mut QpqHandle {
|
||||
let server_str = match cstr_to_str(server) {
|
||||
Some(s) => s,
|
||||
None => return std::ptr::null_mut(),
|
||||
};
|
||||
let ca_cert_str = match cstr_to_str(ca_cert) {
|
||||
Some(s) => s,
|
||||
None => return std::ptr::null_mut(),
|
||||
};
|
||||
let server_name_str = match cstr_to_str(server_name) {
|
||||
Some(s) => s,
|
||||
None => return std::ptr::null_mut(),
|
||||
};
|
||||
|
||||
let rt = match Runtime::new() {
|
||||
Ok(r) => r,
|
||||
Err(_) => return std::ptr::null_mut(),
|
||||
};
|
||||
|
||||
// Verify connectivity by performing a health check.
|
||||
let ca_path = PathBuf::from(ca_cert_str);
|
||||
let connected = rt.block_on(async {
|
||||
quicproquo_client::cmd_health(server_str, &ca_path, server_name_str).await
|
||||
});
|
||||
|
||||
if let Err(e) = connected {
|
||||
// Cannot store error in handle since we failed to build one.
|
||||
eprintln!("qpq_connect: health check failed: {e}");
|
||||
return std::ptr::null_mut();
|
||||
}
|
||||
|
||||
// Derive a default state path from the server address.
|
||||
let state_path = PathBuf::from(format!("qpq-ffi-{server_str}.bin"));
|
||||
|
||||
let handle = Box::new(QpqHandle {
|
||||
runtime: rt,
|
||||
server: server_str.to_string(),
|
||||
ca_cert: ca_path,
|
||||
server_name: server_name_str.to_string(),
|
||||
state_path,
|
||||
state_password: None,
|
||||
logged_in: false,
|
||||
last_error: None,
|
||||
});
|
||||
Box::into_raw(handle)
|
||||
}
|
||||
|
||||
/// Authenticate with the server using OPAQUE (username + password).
|
||||
///
|
||||
/// On success the handle is marked as logged-in and subsequent send/receive
|
||||
/// calls will use the authenticated session.
|
||||
///
|
||||
/// Returns `QPQ_OK` on success, `QPQ_AUTH_FAILED` on bad credentials,
|
||||
/// or `QPQ_ERROR` on other failures.
|
||||
///
|
||||
/// # Safety
|
||||
/// - `handle` must be a valid pointer from `qpq_connect`.
|
||||
/// - `username` and `password` must be valid null-terminated C strings.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn qpq_login(
|
||||
handle: *mut QpqHandle,
|
||||
username: *const c_char,
|
||||
password: *const c_char,
|
||||
) -> i32 {
|
||||
let h = match handle.as_mut() {
|
||||
Some(h) => h,
|
||||
None => return QPQ_NOT_CONNECTED,
|
||||
};
|
||||
|
||||
let user = match cstr_to_str(username) {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
h.set_error("invalid username pointer");
|
||||
return QPQ_ERROR;
|
||||
}
|
||||
};
|
||||
let pass = match cstr_to_str(password) {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
h.set_error("invalid password pointer");
|
||||
return QPQ_ERROR;
|
||||
}
|
||||
};
|
||||
|
||||
// Update state path to be username-specific.
|
||||
h.state_path = PathBuf::from(format!("qpq-ffi-{user}.bin"));
|
||||
|
||||
let result = h.runtime.block_on(async {
|
||||
quicproquo_client::cmd_login(
|
||||
&h.server,
|
||||
&h.ca_cert,
|
||||
&h.server_name,
|
||||
user,
|
||||
pass,
|
||||
None, // identity_key_hex
|
||||
Some(h.state_path.as_path()), // state_path
|
||||
h.state_password.as_deref(), // state_password
|
||||
)
|
||||
.await
|
||||
});
|
||||
|
||||
match result {
|
||||
Ok(()) => {
|
||||
h.logged_in = true;
|
||||
QPQ_OK
|
||||
}
|
||||
Err(e) => {
|
||||
let msg = format!("{e:#}");
|
||||
if msg.contains("auth") || msg.contains("OPAQUE") || msg.contains("credential") {
|
||||
h.set_error(&msg);
|
||||
QPQ_AUTH_FAILED
|
||||
} else {
|
||||
h.set_error(&msg);
|
||||
QPQ_ERROR
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a message to a recipient (by username).
|
||||
///
|
||||
/// The message is encrypted via MLS before delivery. The `message` buffer
|
||||
/// does not need to be null-terminated; `message_len` specifies its length.
|
||||
///
|
||||
/// Returns `QPQ_OK` on success.
|
||||
///
|
||||
/// # Safety
|
||||
/// - `handle` must be a valid pointer from `qpq_connect`.
|
||||
/// - `recipient` must be a valid null-terminated C string.
|
||||
/// - `message` must point to at least `message_len` readable bytes.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn qpq_send(
|
||||
handle: *mut QpqHandle,
|
||||
recipient: *const c_char,
|
||||
message: *const u8,
|
||||
message_len: usize,
|
||||
) -> i32 {
|
||||
let h = match handle.as_mut() {
|
||||
Some(h) => h,
|
||||
None => return QPQ_NOT_CONNECTED,
|
||||
};
|
||||
|
||||
if !h.logged_in {
|
||||
h.set_error("not logged in");
|
||||
return QPQ_NOT_CONNECTED;
|
||||
}
|
||||
|
||||
let rcpt = match cstr_to_str(recipient) {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
h.set_error("invalid recipient pointer");
|
||||
return QPQ_ERROR;
|
||||
}
|
||||
};
|
||||
|
||||
if message.is_null() || message_len == 0 {
|
||||
h.set_error("empty message");
|
||||
return QPQ_ERROR;
|
||||
}
|
||||
let msg_bytes = std::slice::from_raw_parts(message, message_len);
|
||||
let msg_str = match std::str::from_utf8(msg_bytes) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
h.set_error(&format!("message is not valid UTF-8: {e}"));
|
||||
return QPQ_ERROR;
|
||||
}
|
||||
};
|
||||
|
||||
// Resolve recipient username to identity key, then send.
|
||||
let result = h.runtime.block_on(async {
|
||||
let node_client =
|
||||
quicproquo_client::connect_node(&h.server, &h.ca_cert, &h.server_name).await?;
|
||||
let peer_key = quicproquo_client::resolve_user(&node_client, rcpt)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow::anyhow!("recipient '{rcpt}' not found"))?;
|
||||
let peer_key_hex = hex::encode(&peer_key);
|
||||
|
||||
quicproquo_client::cmd_send(
|
||||
&h.state_path,
|
||||
&h.server,
|
||||
&h.ca_cert,
|
||||
&h.server_name,
|
||||
Some(&peer_key_hex),
|
||||
false, // send_to_all
|
||||
msg_str,
|
||||
h.state_password.as_deref(),
|
||||
)
|
||||
.await
|
||||
});
|
||||
|
||||
match result {
|
||||
Ok(()) => QPQ_OK,
|
||||
Err(e) => {
|
||||
h.set_error(&format!("{e:#}"));
|
||||
QPQ_ERROR
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Receive pending messages, blocking up to `timeout_ms` milliseconds.
|
||||
///
|
||||
/// On success, `*out_json` is set to a heap-allocated null-terminated JSON
|
||||
/// string containing an array of received message objects. The caller must
|
||||
/// free this string with `qpq_free_string`.
|
||||
///
|
||||
/// Returns `QPQ_OK` on success (even if the array is empty),
|
||||
/// `QPQ_TIMEOUT` if the wait expires with no messages.
|
||||
///
|
||||
/// # Safety
|
||||
/// - `handle` must be a valid pointer from `qpq_connect`.
|
||||
/// - `out_json` must be a valid pointer to a `*mut c_char`.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn qpq_receive(
|
||||
handle: *mut QpqHandle,
|
||||
timeout_ms: u32,
|
||||
out_json: *mut *mut c_char,
|
||||
) -> i32 {
|
||||
let h = match handle.as_mut() {
|
||||
Some(h) => h,
|
||||
None => return QPQ_NOT_CONNECTED,
|
||||
};
|
||||
|
||||
if !h.logged_in {
|
||||
h.set_error("not logged in");
|
||||
return QPQ_NOT_CONNECTED;
|
||||
}
|
||||
|
||||
if out_json.is_null() {
|
||||
h.set_error("out_json is null");
|
||||
return QPQ_ERROR;
|
||||
}
|
||||
|
||||
let result = h.runtime.block_on(async {
|
||||
quicproquo_client::receive_pending_plaintexts(
|
||||
&h.state_path,
|
||||
&h.server,
|
||||
&h.ca_cert,
|
||||
&h.server_name,
|
||||
timeout_ms as u64,
|
||||
h.state_password.as_deref(),
|
||||
)
|
||||
.await
|
||||
});
|
||||
|
||||
match result {
|
||||
Ok(plaintexts) => {
|
||||
// Convert raw byte payloads to a JSON array of base64 or lossy-UTF-8 strings.
|
||||
let messages: Vec<String> = plaintexts
|
||||
.iter()
|
||||
.map(|pt| String::from_utf8_lossy(pt).into_owned())
|
||||
.collect();
|
||||
|
||||
let json = match serde_json::to_string(&messages) {
|
||||
Ok(j) => j,
|
||||
Err(e) => {
|
||||
h.set_error(&format!("JSON serialisation failed: {e}"));
|
||||
return QPQ_ERROR;
|
||||
}
|
||||
};
|
||||
|
||||
match CString::new(json) {
|
||||
Ok(cs) => {
|
||||
*out_json = cs.into_raw();
|
||||
QPQ_OK
|
||||
}
|
||||
Err(e) => {
|
||||
h.set_error(&format!("CString conversion failed: {e}"));
|
||||
QPQ_ERROR
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let msg = format!("{e:#}");
|
||||
if msg.contains("timeout") || msg.contains("Timeout") {
|
||||
h.set_error(&msg);
|
||||
QPQ_TIMEOUT
|
||||
} else {
|
||||
h.set_error(&msg);
|
||||
QPQ_ERROR
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Disconnect and free the handle.
|
||||
///
|
||||
/// After this call, `handle` must not be used again.
|
||||
///
|
||||
/// # Safety
|
||||
/// `handle` must be a valid pointer from `qpq_connect`, or null (no-op).
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn qpq_disconnect(handle: *mut QpqHandle) {
|
||||
if !handle.is_null() {
|
||||
let _ = Box::from_raw(handle);
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the last error message, or null if no error has been recorded.
|
||||
///
|
||||
/// The returned pointer is valid until the next FFI call on this handle.
|
||||
/// Do **not** free the returned pointer; it is owned by the handle.
|
||||
///
|
||||
/// # Safety
|
||||
/// `handle` must be a valid pointer from `qpq_connect`, or null (returns null).
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn qpq_last_error(handle: *const QpqHandle) -> *const c_char {
|
||||
match handle.as_ref() {
|
||||
Some(h) => match &h.last_error {
|
||||
Some(cs) => cs.as_ptr(),
|
||||
None => std::ptr::null(),
|
||||
},
|
||||
None => std::ptr::null(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Free a string previously returned by `qpq_receive` (via `out_json`).
|
||||
///
|
||||
/// # Safety
|
||||
/// `ptr` must have been allocated by this library (via `CString::into_raw`),
|
||||
/// or null (no-op).
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn qpq_free_string(ptr: *mut c_char) {
|
||||
if !ptr.is_null() {
|
||||
let _ = CString::from_raw(ptr);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user