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:
2026-03-03 23:47:40 +01:00
parent 9ab306d891
commit db46b72f58
16 changed files with 1402 additions and 80 deletions

View 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);
}
}