diff --git a/Cargo.lock b/Cargo.lock index 88f20a6..ad0ae55 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5481,6 +5481,7 @@ dependencies = [ "ciborium", "criterion", "ed25519-dalek 2.2.0", + "getrandom 0.2.17", "hkdf", "hmac", "ml-kem", @@ -5501,6 +5502,17 @@ dependencies = [ "zeroize", ] +[[package]] +name = "quicproquo-ffi" +version = "0.1.0" +dependencies = [ + "anyhow", + "hex", + "quicproquo-client", + "serde_json", + "tokio", +] + [[package]] name = "quicproquo-gen" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 055711a..721beae 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ members = [ "crates/quicproquo-gen", "crates/quicproquo-gui", "crates/quicproquo-mobile", + "crates/quicproquo-ffi", # P2P crate uses iroh (~90 extra deps). Kept in the workspace so it can be # referenced as an optional dependency; only compiled when the `mesh` feature # is enabled on quicproquo-client. diff --git a/README.md b/README.md index 3c8703f..17add3a 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ agreement across any number of participants. Messages are framed with ## Features -### Working +### Core - **Interactive REPL** — multi-conversation chat with auto-register, auto-login, slash commands, background polling, and message history - **1:1 DMs** — dedicated channels with server-enforced membership authorization @@ -51,20 +51,33 @@ agreement across any number of participants. Messages are framed with - **Persistent state** — server and client survive restarts; SQLite/SQLCipher or file-backed storage - **Self-DM notepad** — send messages to yourself (local-only, no server round-trip) - **Certificate pinning** — pass the server cert as `--ca-cert` to trust only that server -- **18 CLI subcommands** — `register-user`, `login`, `create-group`, `invite`, `join`, `send`, `recv`, `chat`, `repl`, and more +- **Federation** — server-to-server message relay via Cap'n Proto RPC over QUIC with mTLS +- **mDNS discovery** — servers announce `_quicproquo._udp.local.`; clients auto-discover nearby nodes +- **Sealed sender mode** — optional anonymous enqueue (sender identity inside MLS ciphertext only) +- **Prometheus metrics** — `--metrics-listen` exposes `/metrics` endpoint for monitoring +- **Dynamic plugin system** — load `.so`/`.dylib` plugins at runtime via `--plugin-dir` +- **Safety numbers** — `/verify ` for out-of-band key verification (60-digit numeric code) +- **Transcript export** — encrypted, tamper-evident message archives with hash-chain integrity verification +- **20 CLI subcommands** — `register-user`, `login`, `create-group`, `invite`, `join`, `send`, `recv`, `chat`, `repl`, `export`, `export-verify`, and more ### REPL slash commands | Command | Description | |---|---| | `/dm ` | Start a 1:1 DM with a peer | -| `/create-group ` | Create a new group | +| `/create-group ` (or `/cg`) | Create a new group | | `/invite ` | Add a member to the current group | +| `/remove ` | Remove a member from the current group | | `/join` | Join a pending group invitation | +| `/leave` | Leave the current group | | `/switch @user` or `/switch #group` | Switch active conversation | | `/list` or `/ls` | List all conversations | | `/members` | Show group members | -| `/history [count]` | Show message history (default 20) | +| `/history [count]` (or `/hist`) | Show message history (default 20) | +| `/verify ` | Compare safety numbers with a peer | +| `/update-key` (or `/rotate-key`) | Rotate your MLS key material | +| `/mesh peers` | Scan for nearby qpq nodes via mDNS | +| `/mesh server ` | Note a discovered server address | | `/whoami` | Show identity and group status | | `/help` | Command reference | | `/quit` | Exit | @@ -73,7 +86,8 @@ agreement across any number of participants. Messages are framed with - **Tauri 2 GUI** (`quicproquo-gui`) — foundational desktop app shell; not feature-complete - **Mobile FFI** (`quicproquo-mobile`) — C API for QUIC connection migration (wifi to cellular) -- **P2P transport** (`quicproquo-p2p`) — iroh-based direct peer-to-peer messaging with NAT traversal (excluded from default build) +- **P2P transport** (`quicproquo-p2p`) — iroh-based direct peer-to-peer messaging with NAT traversal (feature-gated behind `--features mesh`) +- **Bot framework** (`quicproquo-bot`) — programmable bot client --- @@ -84,15 +98,14 @@ agreement across any number of participants. Messages are framed with brew install capnp # macOS # apt-get install capnproto # Debian/Ubuntu -# Build and test -cargo build --workspace -cargo test --workspace +# Build (excludes GUI — requires GTK system libs) +cargo build --bin qpq-server --bin qpq -# Start the server (port 7000 by default) -cargo run --bin qpq-server +# Run tests +cargo test --workspace --exclude quicproquo-gui -# Run the two-party demo -cargo run --bin qpq -- demo-group --server 127.0.0.1:7000 +# Start the server (port 7000 by default, auto-generates self-signed cert) +cargo run --bin qpq-server -- --allow-insecure-auth # Interactive REPL (auto-registers and logs in) cargo run --bin qpq -- repl --username alice --password mypass @@ -122,15 +135,23 @@ listen = "0.0.0.0:7000" data_dir = "data" tls_cert = "data/server-cert.der" tls_key = "data/server-key.der" -auth_token = "devtoken" +auth_token = "your-strong-token-here" store_backend = "sql" # or "file" db_path = "data/qpq.db" -db_key = "" # set for SQLCipher encryption +db_key = "your-db-encryption-key" +metrics_listen = "0.0.0.0:9090" +metrics_enabled = true +# Federation (optional) +# federation_enabled = true +# federation_domain = "chat.example.com" +# federation_listen = "0.0.0.0:7001" +# Plugin loading (optional) +# plugin_dir = "/etc/qpq/plugins" EOF cargo run --bin qpq-server -- --config qpq-server.toml ``` -> **Production:** set `QPQ_PRODUCTION=1`, use a strong `QPQ_AUTH_TOKEN` (not `devtoken`), and set `QPQ_DB_KEY` when using `store_backend = "sql"`. +> **Production:** use a strong `QPQ_AUTH_TOKEN`, set `QPQ_DB_KEY` when using `store_backend = "sql"`, and provide real TLS certificates (the server refuses to auto-generate certs in production mode). See the [full demo walkthrough](docs/src/getting-started/demo-walkthrough.md) for a step-by-step guide. @@ -142,11 +163,30 @@ See the [full demo walkthrough](docs/src/getting-started/demo-walkthrough.md) fo |---|---| | `quicproquo-core` | MLS group operations, hybrid KEM, OPAQUE auth, crypto primitives | | `quicproquo-proto` | Cap'n Proto schemas and generated RPC code | -| `quicproquo-server` | QUIC server, NodeService RPC, storage backends | +| `quicproquo-server` | QUIC server, NodeService RPC, storage backends, federation, plugins | | `quicproquo-client` | CLI + REPL, session management, conversation store | -| `quicproquo-gui` | Tauri 2 desktop app (experimental) | +| `quicproquo-plugin-api` | C-compatible plugin hook API (`HookVTable`) | +| `quicproquo-kt` | Key transparency / Merkle-log identity bindings | +| `quicproquo-bot` | Programmable bot client framework | +| `quicproquo-gen` | Code generation utilities | +| `quicproquo-gui` | Tauri 2 desktop app (experimental, requires GTK) | | `quicproquo-mobile` | C FFI for mobile connection migration (experimental) | -| `quicproquo-p2p` | iroh-based P2P transport (experimental, excluded from workspace) | +| `quicproquo-p2p` | iroh-based P2P transport (feature-gated, `--features mesh`) | + +--- + +## CI pipeline + +GitHub Actions runs on every push and PR: + +- `cargo fmt --check` — formatting +- `cargo build --workspace` — full build +- `cargo test --workspace` — 103+ tests (core, server, client, E2E, doctests) +- `cargo clippy --workspace` — lint +- `cargo deny check` — license and advisory audit +- `cargo audit` — vulnerability scan +- `cargo tarpaulin` — code coverage (uploaded as artifact) +- `docker build` — container image validation --- @@ -157,10 +197,10 @@ See the [full demo walkthrough](docs/src/getting-started/demo-walkthrough.md) fo | M1 | QUIC/TLS transport | **Done** | QUIC + TLS 1.3 endpoint, length-prefixed framing, Ping/Pong | | M2 | Authentication Service | **Done** | Ed25519 identity, KeyPackage generation, AS upload/fetch | | M3 | Delivery Service + MLS groups | **Done** | DS relay, `GroupMember` create/join/add/send/recv | -| M4 | Group CLI subcommands | **Done** | Persistent CLI, OPAQUE login, 18 subcommands | +| M4 | Group CLI subcommands | **Done** | Persistent CLI, OPAQUE login, 20 subcommands | | M5 | Multi-party groups | **Done** | N > 2 members, Commit fan-out, `send --all`, epoch sync | | M6 | Persistence + REPL | **Done** | SQLite/SQLCipher, interactive REPL, DM channels, encrypted local storage | -| M7 | Post-quantum MLS | **Next** | Hybrid X25519 + ML-KEM-768 integrated into MLS ciphersuite | +| M7 | Post-quantum MLS | **Planned** | Hybrid X25519 + ML-KEM-768 integrated into MLS ciphersuite | M7 note: the hybrid KEM envelope is already implemented and tested (10 tests passing). What remains is integrating it into the OpenMLS CryptoProvider so all MLS key material gets post-quantum confidentiality. @@ -168,40 +208,45 @@ M7 note: the hybrid KEM envelope is already implemented and tested (10 tests pas ## Roadmap -### Next up +See [ROADMAP.md](ROADMAP.md) for the full phased plan. Summary: -- **Post-quantum MLS integration** (M7) — hybrid KEM into the MLS key schedule -- **Full MLS lifecycle** — member removal, credential updates, proposal handling -- **CI pipeline** — GitHub Actions (test, clippy, fmt, audit) -- **Accounts & devices model** — per-account rate limits, multi-device support -- **Client offline queue** — idempotent message IDs, gap detection, retry +| Phase | Focus | Status | +|-------|-------|--------| +| 1 | Production hardening (unwrap removal, secure defaults, Docker) | In progress | +| 2 | Test and CI maturity | Partially done | +| 3 | Client SDKs (Go, Python, WASM, FFI, WebTransport) | Planned | +| 4 | Trust and security (audit, key transparency, PQ MLS) | Planned | +| 5 | Features and UX (multi-device, offline queue, file transfer) | Planned | +| 6 | Scale and operations (horizontal scaling, observability) | Planned | +| 7 | Platform expansion (mobile, web, federation, sealed sender) | Planned | +| 8 | Freifunk / community mesh networking | F0-F2 done | +| 9 | Developer experience and community growth | Planned | -### Planned +### Recently completed -- Server-to-server federation (mTLS relay, in progress) -- CA-signed TLS / Let's Encrypt support -- HTTP health endpoint for load balancers -- Connection draining and graceful shutdown -- Wire versioning and N-1 compatibility - -### Research - -- Sealed sender (metadata resistance) — foundation exists -- Traffic analysis resistance (padding + shaping) -- P2P / NAT traversal via iroh — crate started -- WebTransport for browser clients -- Tor / I2P routing -- Private information retrieval for message fetch +- **Federation routing** — server-to-server message relay with mTLS +- **mDNS discovery** — servers advertise on local network, clients discover peers +- **P2P transport** — iroh-based direct messaging re-included in workspace (`--features mesh`) +- **CI pipeline** — fmt, build, test, clippy, deny, audit, coverage, Docker build +- **Plugin system** — dynamic `.so`/`.dylib` loading with C-compatible hook API +- **Safety numbers** — Signal-style 60-digit verification codes +- **Transcript export** — encrypted, hash-chained message archives --- ## Building without the GUI +The GUI crate requires GTK system libraries. To build just the server and client: + ```bash cargo build --bin qpq-server --bin qpq ``` -Core and proto crates are built as dependencies automatically. +To build the client with mesh/P2P support: + +```bash +cargo build -p quicproquo-client --features mesh +``` --- @@ -234,7 +279,8 @@ This is a **research project** and has not undergone a formal third-party audit. - Local databases are encrypted with SQLCipher when a password is provided. - Session tokens are encrypted at rest (Argon2id key derivation + ChaCha20-Poly1305). - **Certificate pinning:** pass the server cert as `--ca-cert` so the client trusts only that server. -- **Dependency checks:** `cargo install cargo-audit && cargo audit` +- **Sealed sender:** optional mode where the server cannot see who sent a message. +- **Dependency checks:** CI runs `cargo deny check` and `cargo audit` on every PR. --- diff --git a/crates/quicproquo-core/Cargo.toml b/crates/quicproquo-core/Cargo.toml index fbea760..07a6e3d 100644 --- a/crates/quicproquo-core/Cargo.toml +++ b/crates/quicproquo-core/Cargo.toml @@ -5,8 +5,25 @@ edition = "2021" description = "Crypto primitives, MLS state machine, and hybrid post-quantum KEM for quicproquo." license = "MIT" +[features] +default = ["native"] +# The "native" feature enables MLS (openmls), OPAQUE, Cap'n Proto, tokio, and +# filesystem-backed key storage. Disable it (--no-default-features) to compile +# the pure-crypto subset to wasm32-unknown-unknown. +native = [ + "dep:openmls", + "dep:openmls_rust_crypto", + "dep:openmls_traits", + "dep:tls_codec", + "dep:opaque-ke", + "dep:bincode", + "dep:capnp", + "dep:quicproquo-proto", + "dep:tokio", +] + [dependencies] -# Crypto — classical +# Crypto — classical (always available, WASM-safe) x25519-dalek = { workspace = true } ed25519-dalek = { workspace = true } sha2 = { workspace = true } @@ -16,32 +33,34 @@ ciborium = { workspace = true } chacha20poly1305 = { workspace = true } zeroize = { workspace = true } rand = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +argon2 = { workspace = true } +thiserror = { workspace = true } -# Crypto — post-quantum hybrid KEM (M7) +# Crypto — post-quantum hybrid KEM (M7) — always available, WASM-safe ml-kem = { workspace = true } -# Crypto — OPAQUE password-authenticated key exchange -opaque-ke = { workspace = true } -argon2 = { workspace = true } +# Crypto — OPAQUE password-authenticated key exchange (native only) +opaque-ke = { workspace = true, optional = true } -# Crypto — MLS (M2) -openmls = { workspace = true } -openmls_rust_crypto = { workspace = true } -openmls_traits = { workspace = true } -tls_codec = { workspace = true } -serde = { workspace = true } -bincode = { workspace = true } -serde_json = { workspace = true } +# Crypto — MLS (M2) (native only) +openmls = { workspace = true, optional = true } +openmls_rust_crypto = { workspace = true, optional = true } +openmls_traits = { workspace = true, optional = true } +tls_codec = { workspace = true, optional = true } +bincode = { workspace = true, optional = true } -# Serialisation -capnp = { workspace = true } -quicproquo-proto = { path = "../quicproquo-proto" } +# Serialisation (native only) +capnp = { workspace = true, optional = true } +quicproquo-proto = { path = "../quicproquo-proto", optional = true } -# Async runtime -tokio = { workspace = true } +# Async runtime (native only) +tokio = { workspace = true, optional = true } -# Error handling -thiserror = { workspace = true } +# WASM: provide getrandom with js backend +[target.'cfg(target_arch = "wasm32")'.dependencies] +getrandom = { version = "0.2", features = ["js"] } [lints] workspace = true diff --git a/crates/quicproquo-core/src/error.rs b/crates/quicproquo-core/src/error.rs index 2a7d3a8..844299f 100644 --- a/crates/quicproquo-core/src/error.rs +++ b/crates/quicproquo-core/src/error.rs @@ -6,6 +6,7 @@ use thiserror::Error; #[derive(Debug, Error)] pub enum CoreError { /// Cap'n Proto serialisation or deserialisation failed. + #[cfg(feature = "native")] #[error("Cap'n Proto error: {0}")] Capnp(#[from] capnp::Error), diff --git a/crates/quicproquo-core/src/identity.rs b/crates/quicproquo-core/src/identity.rs index c70571b..8b2f92f 100644 --- a/crates/quicproquo-core/src/identity.rs +++ b/crates/quicproquo-core/src/identity.rs @@ -17,8 +17,6 @@ //! collision-resistant identifier for logging. use ed25519_dalek::{Signer as DalekSigner, SigningKey, VerifyingKey}; -use openmls_traits::signatures::Signer; -use openmls_traits::types::{Error as MlsError, SignatureScheme}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use zeroize::Zeroizing; @@ -87,15 +85,16 @@ impl IdentityKeypair { /// Implement the openmls `Signer` trait so `IdentityKeypair` can be passed /// directly to `KeyPackage::builder().build(...)` without needing the external /// `openmls_basic_credential` crate. -impl Signer for IdentityKeypair { - fn sign(&self, payload: &[u8]) -> Result, MlsError> { +#[cfg(feature = "native")] +impl openmls_traits::signatures::Signer for IdentityKeypair { + fn sign(&self, payload: &[u8]) -> Result, openmls_traits::types::Error> { let sk = self.signing_key(); let sig: ed25519_dalek::Signature = sk.sign(payload); Ok(sig.to_bytes().to_vec()) } - fn signature_scheme(&self) -> SignatureScheme { - SignatureScheme::ED25519 + fn signature_scheme(&self) -> openmls_traits::types::SignatureScheme { + openmls_traits::types::SignatureScheme::ED25519 } } diff --git a/crates/quicproquo-core/src/lib.rs b/crates/quicproquo-core/src/lib.rs index 990c923..23569c0 100644 --- a/crates/quicproquo-core/src/lib.rs +++ b/crates/quicproquo-core/src/lib.rs @@ -1,6 +1,28 @@ //! Core cryptographic primitives, MLS group state machine, and hybrid //! post-quantum KEM for quicproquo. //! +//! # WASM support +//! +//! When compiled with `--no-default-features` (disabling the `native` feature), +//! the following modules are available for `wasm32-unknown-unknown`: +//! +//! - `identity` — Ed25519 identity keypair (generate, sign, verify) +//! - `hybrid_kem` — X25519 + ML-KEM-768 hybrid key encapsulation +//! - `safety_numbers` — Signal-style safety number computation +//! - `sealed_sender` — sender identity + Ed25519 signature envelope +//! - `app_message` — rich application message serialisation/parsing +//! - `padding` — message padding to hide plaintext lengths +//! - `transcript` — encrypted tamper-evident message transcript +//! - `error` — `CoreError` type +//! +//! The following modules require the `native` feature (MLS, OPAQUE, Cap'n Proto): +//! +//! - `group` — MLS group state machine (openmls) +//! - `keypackage` — MLS KeyPackage generation +//! - `hybrid_crypto` — hybrid HPKE provider for OpenMLS +//! - `keystore` — OpenMLS key store with optional disk persistence +//! - `opaque_auth` — OPAQUE cipher suite configuration +//! //! # Module layout //! //! | Module | Responsibility | @@ -15,36 +37,50 @@ mod app_message; mod error; -mod group; -mod hybrid_crypto; mod hybrid_kem; mod identity; -mod keypackage; -mod keystore; -pub mod opaque_auth; pub mod padding; pub mod safety_numbers; pub mod sealed_sender; pub mod transcript; -// ── Public API ──────────────────────────────────────────────────────────────── +// ── Native-only modules (MLS, OPAQUE, filesystem) ─────────────────────────── +#[cfg(feature = "native")] +mod group; +#[cfg(feature = "native")] +mod hybrid_crypto; +#[cfg(feature = "native")] +mod keypackage; +#[cfg(feature = "native")] +mod keystore; +#[cfg(feature = "native")] +pub mod opaque_auth; + +// ── Public API (always available) ─────────────────────────────────────────── pub use app_message::{ serialize, serialize_chat, serialize_reaction, serialize_read_receipt, serialize_reply, serialize_typing, parse, generate_message_id, AppMessage, MessageType, VERSION as APP_MESSAGE_VERSION, }; pub use error::CoreError; -pub use group::{GroupMember, ReceivedMessage, ReceivedMessageWithSender}; pub use hybrid_kem::{ hybrid_decrypt, hybrid_encrypt, HybridKemError, HybridKeypair, HybridKeypairBytes, HybridPublicKey, }; -pub use hybrid_crypto::{HybridCrypto, HybridCryptoProvider}; pub use identity::{verify_delivery_proof, IdentityKeypair}; -pub use keypackage::{generate_key_package, validate_keypackage_ciphersuite}; -pub use keystore::DiskKeyStore; pub use safety_numbers::compute_safety_number; pub use transcript::{ read_transcript, verify_transcript_chain, ChainVerdict, DecodedRecord, TranscriptRecord, TranscriptWriter, }; + +// ── Public API (native only) ──────────────────────────────────────────────── + +#[cfg(feature = "native")] +pub use group::{GroupMember, ReceivedMessage, ReceivedMessageWithSender}; +#[cfg(feature = "native")] +pub use hybrid_crypto::{HybridCrypto, HybridCryptoProvider}; +#[cfg(feature = "native")] +pub use keypackage::{generate_key_package, validate_keypackage_ciphersuite}; +#[cfg(feature = "native")] +pub use keystore::DiskKeyStore; diff --git a/crates/quicproquo-ffi/Cargo.toml b/crates/quicproquo-ffi/Cargo.toml new file mode 100644 index 0000000..0ed2d4e --- /dev/null +++ b/crates/quicproquo-ffi/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "quicproquo-ffi" +version = "0.1.0" +edition = "2021" +description = "C FFI bindings for quicproquo messaging operations." +license = "MIT" + +[lib] +crate-type = ["cdylib", "staticlib"] + +[dependencies] +quicproquo-client = { path = "../quicproquo-client" } +tokio = { workspace = true } +anyhow = { workspace = true } +serde_json = { workspace = true } +hex = { workspace = true } + +[lints] +workspace = true diff --git a/crates/quicproquo-ffi/src/lib.rs b/crates/quicproquo-ffi/src/lib.rs new file mode 100644 index 0000000..955935c --- /dev/null +++ b/crates/quicproquo-ffi/src/lib.rs @@ -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, + logged_in: bool, + last_error: Option, +} + +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 = 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); + } +} diff --git a/docker/Dockerfile b/docker/Dockerfile index 0211423..51974b5 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -23,6 +23,7 @@ COPY crates/quicproquo-kt/Cargo.toml crates/quicproquo-kt/Cargo.toml COPY crates/quicproquo-plugin-api/Cargo.toml crates/quicproquo-plugin-api/Cargo.toml COPY crates/quicproquo-gui/Cargo.toml crates/quicproquo-gui/Cargo.toml COPY crates/quicproquo-mobile/Cargo.toml crates/quicproquo-mobile/Cargo.toml +COPY crates/quicproquo-ffi/Cargo.toml crates/quicproquo-ffi/Cargo.toml # Create dummy source files so `cargo build` can resolve the dependency graph # and cache the compiled dependencies before copying real source. @@ -38,6 +39,7 @@ RUN mkdir -p \ crates/quicproquo-plugin-api/src \ crates/quicproquo-gui/src \ crates/quicproquo-mobile/src \ + crates/quicproquo-ffi/src \ && echo 'fn main() {}' > crates/quicproquo-server/src/main.rs \ && echo 'fn main() {}' > crates/quicproquo-client/src/main.rs \ && echo 'fn main() {}' > crates/quicproquo-gen/src/main.rs \ @@ -48,7 +50,8 @@ RUN mkdir -p \ && touch crates/quicproquo-kt/src/lib.rs \ && touch crates/quicproquo-plugin-api/src/lib.rs \ && touch crates/quicproquo-gui/src/lib.rs \ - && touch crates/quicproquo-mobile/src/lib.rs + && touch crates/quicproquo-mobile/src/lib.rs \ + && touch crates/quicproquo-ffi/src/lib.rs # Schemas must exist before the proto crate's build.rs runs. COPY schemas/ schemas/ diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 770a562..e0a66ca 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -21,6 +21,9 @@ - [Certificate Lifecycle and CA-Signed TLS](getting-started/certificate-lifecycle.md) - [Docker Deployment](getting-started/docker.md) - [Bot SDK](getting-started/bot-sdk.md) +- [C FFI Bindings](getting-started/ffi.md) +- [WASM Integration](getting-started/wasm.md) +- [Code Generators (qpq-gen)](getting-started/generators.md) - [Demo Walkthrough: Alice and Bob](getting-started/demo-walkthrough.md) --- diff --git a/docs/src/getting-started/ffi.md b/docs/src/getting-started/ffi.md new file mode 100644 index 0000000..4246784 --- /dev/null +++ b/docs/src/getting-started/ffi.md @@ -0,0 +1,243 @@ +# C FFI Bindings + +The `quicproquo-ffi` crate provides a synchronous C API for the quicproquo +messaging client. It wraps the async `quicproquo-client` library behind an +opaque handle, so C, Python, Swift, or any language with C FFI support can +connect, authenticate, send messages, and receive messages. + +Each `QpqHandle` owns a Tokio runtime internally; FFI functions use +`runtime.block_on()` to bridge synchronous C callers to the async Rust +internals. + +## Building + +```bash +# Shared library (.so / .dylib / .dll) + static archive (.a) +cargo build --release -p quicproquo-ffi +``` + +Output: + +| Platform | Shared library | Static library | +|----------|---------------------------------------------|-----------------------------------------| +| Linux | `target/release/libquicproquo_ffi.so` | `target/release/libquicproquo_ffi.a` | +| macOS | `target/release/libquicproquo_ffi.dylib` | `target/release/libquicproquo_ffi.a` | +| Windows | `target/release/quicproquo_ffi.dll` | `target/release/quicproquo_ffi.lib` | + +## Status Codes + +| Code | Constant | Meaning | +|------|--------------------|------------------------------------------| +| 0 | `QPQ_OK` | Success | +| 1 | `QPQ_ERROR` | Generic error (check `qpq_last_error`) | +| 2 | `QPQ_AUTH_FAILED` | OPAQUE authentication failed | +| 3 | `QPQ_TIMEOUT` | Receive timed out with no messages | +| 4 | `QPQ_NOT_CONNECTED`| Handle is null or not logged in | + +## C API Reference + +All functions use the `extern "C"` calling convention. All string parameters +must be valid, non-null, null-terminated UTF-8. The opaque handle type is +`QpqHandle *`. + +### `qpq_connect` + +```c +QpqHandle *qpq_connect( + const char *server, /* "host:port", e.g. "127.0.0.1:7000" */ + const char *ca_cert, /* path to CA certificate file (DER) */ + const char *server_name /* TLS server name, e.g. "localhost" */ +); +``` + +Creates a Tokio runtime, performs a health check against the server, and +returns a heap-allocated opaque handle. Returns `NULL` on failure (invalid +arguments, server unreachable, or runtime creation failed). + +### `qpq_login` + +```c +int32_t qpq_login( + QpqHandle *handle, /* handle from qpq_connect */ + const char *username, /* OPAQUE username */ + const char *password /* OPAQUE password */ +); +``` + +Authenticates with the server using OPAQUE (password-authenticated key +exchange). On success the handle is marked as logged-in and subsequent +`qpq_send`/`qpq_receive` calls use the authenticated session. + +**Returns:** `QPQ_OK` on success, `QPQ_AUTH_FAILED` on bad credentials, +`QPQ_NOT_CONNECTED` if the handle is null, or `QPQ_ERROR` on other failures. + +### `qpq_send` + +```c +int32_t qpq_send( + QpqHandle *handle, /* handle from qpq_connect */ + const char *recipient, /* recipient username (null-terminated) */ + const uint8_t *message, /* message bytes (UTF-8, not null-terminated) */ + size_t message_len /* length of message in bytes */ +); +``` + +Resolves the recipient by username, then sends an MLS-encrypted message +through the server. The `message` buffer must contain valid UTF-8 of at least +`message_len` bytes. The handle must be logged in. + +**Returns:** `QPQ_OK` on success, `QPQ_NOT_CONNECTED` if not logged in, or +`QPQ_ERROR` on failure (recipient not found, network error, etc.). + +### `qpq_receive` + +```c +int32_t qpq_receive( + QpqHandle *handle, /* handle from qpq_connect */ + uint32_t timeout_ms, /* maximum wait time in milliseconds */ + char **out_json /* output: heap-allocated JSON string */ +); +``` + +Blocks up to `timeout_ms` milliseconds waiting for pending messages. On +success, `*out_json` points to a null-terminated JSON string containing an +array of decrypted message strings (e.g., `["hello","world"]`). 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, `QPQ_NOT_CONNECTED` if +not logged in, or `QPQ_ERROR` on failure. + +### `qpq_disconnect` + +```c +void qpq_disconnect(QpqHandle *handle); +``` + +Shuts down the Tokio runtime and frees the handle. After this call, the +handle must not be used again. Passing `NULL` is a safe no-op. + +### `qpq_last_error` + +```c +const char *qpq_last_error(const QpqHandle *handle); +``` + +Returns the last error message recorded on the handle, or `NULL` if no error +has occurred. The returned pointer is valid **only** until the next FFI call +on the same handle. Do **not** free this pointer -- it is owned by the handle. + +### `qpq_free_string` + +```c +void qpq_free_string(char *ptr); +``` + +Frees a string previously returned by `qpq_receive` via the `out_json` +output parameter. Passing `NULL` is a safe no-op. Do **not** use this to +free strings from `qpq_last_error`. + +## Memory Management Rules + +1. **`QpqHandle`** is heap-allocated by `qpq_connect` and freed by + `qpq_disconnect`. Do not use the handle after disconnecting. +2. **`out_json` from `qpq_receive`** is heap-allocated. Free it with + `qpq_free_string`. +3. **`qpq_last_error`** returns a pointer owned by the handle. Do not free + it; it is valid until the next FFI call on the same handle. +4. All `const char *` input parameters are borrowed for the duration of the + call and not stored beyond it. + +## Error Handling Pattern + +Every function that returns `int32_t` uses the status codes above. The +recommended pattern is: + +```c +int rc = qpq_login(handle, "alice", "password123"); +if (rc != QPQ_OK) { + const char *err = qpq_last_error(handle); + fprintf(stderr, "login failed (code %d): %s\n", rc, err ? err : "unknown"); + qpq_disconnect(handle); + return 1; +} +``` + +## Example: C Usage + +```c +#include +#include + +/* Link with: -lquicproquo_ffi -lpthread -ldl -lm */ + +typedef struct QpqHandle QpqHandle; + +extern QpqHandle *qpq_connect(const char *, const char *, const char *); +extern int qpq_login(QpqHandle *, const char *, const char *); +extern int qpq_send(QpqHandle *, const char *, const unsigned char *, unsigned long); +extern int qpq_receive(QpqHandle *, unsigned int, char **); +extern void qpq_disconnect(QpqHandle *); +extern const char *qpq_last_error(const QpqHandle *); +extern void qpq_free_string(char *); + +#define QPQ_OK 0 + +int main(void) { + QpqHandle *h = qpq_connect("127.0.0.1:7000", "server-cert.der", "localhost"); + if (!h) { + fprintf(stderr, "connection failed\n"); + return 1; + } + + if (qpq_login(h, "alice", "secret") != QPQ_OK) { + fprintf(stderr, "login failed: %s\n", qpq_last_error(h)); + qpq_disconnect(h); + return 1; + } + + /* Send a message */ + const char *msg = "hello from C"; + if (qpq_send(h, "bob", (const unsigned char *)msg, strlen(msg)) != QPQ_OK) { + fprintf(stderr, "send failed: %s\n", qpq_last_error(h)); + } + + /* Receive messages (5 second timeout) */ + char *json = NULL; + int rc = qpq_receive(h, 5000, &json); + if (rc == QPQ_OK && json) { + printf("received: %s\n", json); + qpq_free_string(json); + } + + qpq_disconnect(h); + return 0; +} +``` + +Compile and link: + +```bash +gcc -o qpq_demo qpq_demo.c -L target/release -lquicproquo_ffi -lpthread -ldl -lm +LD_LIBRARY_PATH=target/release ./qpq_demo +``` + +## Python Bindings + +A ready-made Python `ctypes` wrapper is provided in +[`examples/python/qpq_client.py`](https://github.com/nickvidal/quicproquo/tree/main/examples/python). + +```bash +# Build the FFI library first +cargo build --release -p quicproquo-ffi + +# Run the Python client +python examples/python/qpq_client.py \ + --server 127.0.0.1:7000 \ + --ca-cert server-cert.der \ + --username alice --password secret \ + --receive --timeout 5000 +``` + +Set `QPQ_FFI_LIB=/path/to/libquicproquo_ffi.so` to override automatic +library discovery. diff --git a/docs/src/getting-started/generators.md b/docs/src/getting-started/generators.md new file mode 100644 index 0000000..436a424 --- /dev/null +++ b/docs/src/getting-started/generators.md @@ -0,0 +1,171 @@ +# Code Generators (qpq-gen) + +The `qpq-gen` CLI tool scaffolds new plugins, bots, RPC methods, and hook +events for the quicproquo ecosystem. + +## Installation + +```bash +cargo install --path crates/quicproquo-gen +``` + +Or run directly from the workspace: + +```bash +cargo run -p quicproquo-gen -- +``` + +## Subcommands + +### `qpq-gen plugin ` -- Server Plugin + +Scaffolds a standalone Cargo project for a server plugin compiled as a shared +library (`cdylib`). The generated plugin implements the `HookVTable` C ABI +and is loaded by the server at startup via `--plugin-dir`. + +```bash +qpq-gen plugin rate-limiter +qpq-gen plugin audit-log --output /tmp/plugins +``` + +**Generated files:** + +``` +rate_limiter/ + Cargo.toml # cdylib crate depending on quicproquo-plugin-api + README.md # Build and install instructions + src/lib.rs # Plugin skeleton with qpq_plugin_init entry point +``` + +The template includes: +- `qpq_plugin_init` -- called by the server on load; populates the `HookVTable` +- `on_message_enqueue` -- sample hook that rejects payloads larger than 1 MB +- `error_message` -- returns the rejection reason as a C string +- `destroy` -- frees the plugin state + +**What to customize:** Replace the `on_message_enqueue` logic with your own +policy. Add more hooks by setting additional fields on the `HookVTable` +(`on_auth`, `on_channel_created`, `on_fetch`, `on_user_registered`, +`on_batch_enqueue`). + +**Build and install:** + +```bash +cd rate_limiter +cargo build --release +cp target/release/librate_limiter.so /path/to/plugins/ +qpq-server --plugin-dir /path/to/plugins/ +``` + +### `qpq-gen bot ` -- Bot Project + +Scaffolds a standalone bot project using the Bot SDK. The generated binary +connects to a quicproquo server, authenticates via OPAQUE, and runs a +message-handling loop. + +```bash +qpq-gen bot echo-bot +qpq-gen bot moderation-bot --output /tmp/bots +``` + +**Generated files:** + +``` +moderation_bot/ + Cargo.toml # Binary crate depending on quicproquo-bot + tokio + README.md # Quick-start and command reference + src/main.rs # Bot skeleton with handle_message dispatcher +``` + +The template ships with four built-in commands as examples: + +| Command | Description | +|-----------------|---------------------------| +| `!help` | List available commands | +| `!echo ` | Echo back the text | +| `!whoami` | Show the sender's username| +| `!ping` | Respond with "pong!" | + +**Configuration** is read from environment variables: + +| Variable | Default | +|-------------------|----------------------| +| `QPQ_SERVER` | `127.0.0.1:7000` | +| `QPQ_USERNAME` | `` | +| `QPQ_PASSWORD` | `changeme` | +| `QPQ_CA_CERT` | `server-cert.der` | +| `QPQ_STATE_PATH` | `-state.bin` | + +**What to customize:** Edit the `handle_message` function in `src/main.rs` +to add your own command handlers. Return `Some(response)` to reply, or +`None` to stay silent. + +**Run:** + +```bash +cd moderation_bot +QPQ_SERVER=127.0.0.1:7000 \ +QPQ_USERNAME=moderation_bot \ +QPQ_PASSWORD=changeme \ +QPQ_CA_CERT=path/to/server-cert.der \ +cargo run +``` + +### `qpq-gen rpc ` -- RPC Method Guide + +Prints a step-by-step guide for adding a new Cap'n Proto RPC method to the +server. This generator does not create files; it outputs instructions and +code snippets to copy into the appropriate locations. + +```bash +qpq-gen rpc listChannels +``` + +The `Name` argument should be in camelCase (e.g., `listChannels`). The +generator derives the `snake_case` form automatically for file and function +names. + +**Steps covered:** + +1. **Schema** -- Add the method to the `interface NodeService` block in + `schemas/node.capnp`, then rebuild with `cargo build -p quicproquo-proto` +2. **Handler module** -- Create + `crates/quicproquo-server/src/node_service/.rs` with the handler + implementation (template code is printed) +3. **Registration** -- Wire the handler into `node_service/mod.rs` +4. **Storage** (if needed) -- Add a method to the `Store` trait and implement + it in `sql_store.rs` and `storage.rs` +5. **Hook** (optional) -- Run `qpq-gen hook ` to let plugins observe + the new RPC +6. **Verify** -- `cargo build -p quicproquo-server && cargo test -p quicproquo-server` + +### `qpq-gen hook ` -- Hook Event Guide + +Prints a step-by-step guide for adding a new server hook event that plugins +can observe. Like `rpc`, this generator outputs instructions rather than +creating files. + +```bash +qpq-gen hook message_deleted +``` + +The `name` argument should be in `snake_case` (e.g., `message_deleted`). The +generator derives the `PascalCase` form for struct names. + +**Steps covered:** + +1. **Event struct** -- Define `MessageDeletedEvent` in + `crates/quicproquo-server/src/hooks.rs` +2. **Trait method** -- Add `on_message_deleted` to the `ServerHooks` trait + with a default no-op implementation +3. **Tracing** -- Implement the hook in `TracingHooks` with a `tracing::info!` + call +4. **Plugin API** -- Add a C-compatible `CMessageDeletedEvent` struct and an + `on_message_deleted` field to `HookVTable` in + `crates/quicproquo-plugin-api/src/lib.rs` +5. **Plugin dispatch** -- Wire the conversion and dispatch in + `plugin_loader.rs` +6. **Call site** -- Fire the hook from the relevant RPC handler in + `node_service/` +7. **Verify** -- Build and test `quicproquo-plugin-api` and + `quicproquo-server` diff --git a/docs/src/getting-started/wasm.md b/docs/src/getting-started/wasm.md new file mode 100644 index 0000000..7deaeac --- /dev/null +++ b/docs/src/getting-started/wasm.md @@ -0,0 +1,70 @@ +# WASM Integration + +The `quicproquo-core` crate supports compilation to `wasm32-unknown-unknown` +when the `native` feature is disabled. This exposes the pure-crypto subset of +the library for use in browsers or other WASM runtimes. + +## Building for WASM + +```bash +rustup target add wasm32-unknown-unknown + +cargo build -p quicproquo-core \ + --target wasm32-unknown-unknown \ + --no-default-features +``` + +The `--no-default-features` flag disables the `native` feature, which gates +MLS (openmls), OPAQUE, Cap'n Proto, and tokio -- all of which have dependencies +that do not compile to WASM. + +## What is available in WASM mode + +The following modules compile to WASM and are fully functional: + +| Module | Description | +|-------------------|----------------------------------------------------| +| `identity` | Ed25519 identity keypair (generate, sign, verify) | +| `hybrid_kem` | X25519 + ML-KEM-768 hybrid key encapsulation | +| `safety_numbers` | Signal-style safety number computation | +| `sealed_sender` | Sender identity + Ed25519 signature envelope | +| `app_message` | Rich application message serialisation/parsing | +| `padding` | Message padding to hide plaintext lengths | +| `transcript` | Encrypted tamper-evident message transcript | +| `error` | `CoreError` type | + +## What is NOT available in WASM mode + +The following require the `native` feature and will not compile to WASM: + +- `group` -- MLS group state machine (openmls) +- `keypackage` -- MLS KeyPackage generation +- `hybrid_crypto` -- hybrid HPKE provider for OpenMLS +- `keystore` -- OpenMLS key store with disk persistence +- `opaque_auth` -- OPAQUE cipher suite configuration + +Networking (`quicproquo-client`, `quicproquo-server`) is not available in WASM. + +## Random number generation + +On `wasm32`, the `getrandom` crate is configured with the `js` feature to +use the browser's `crypto.getRandomValues()` API. This is set automatically +in `quicproquo-core/Cargo.toml`: + +```toml +[target.'cfg(target_arch = "wasm32")'.dependencies] +getrandom = { version = "0.2", features = ["js"] } +``` + +This means the WASM build works in browser environments out of the box. +For non-browser WASM runtimes (WASI, etc.), you may need to adjust the +`getrandom` backend. + +## Future plans + +- **wasm-bindgen JS bindings**: Wrap the WASM-compatible modules with + `#[wasm_bindgen]` annotations to provide a native JavaScript/TypeScript API. + This would allow web frontends to perform client-side encryption without a + server round-trip. +- **wasm-pack integration**: Publish the WASM module as an npm package for + easy consumption in web projects. diff --git a/examples/python/README.md b/examples/python/README.md new file mode 100644 index 0000000..86a44d4 --- /dev/null +++ b/examples/python/README.md @@ -0,0 +1,68 @@ +# quicproquo Python FFI Client + +Python wrapper around `libquicproquo_ffi` using `ctypes`. + +## Build the FFI library + +```bash +cargo build --release -p quicproquo-ffi +``` + +This produces: + +- Linux: `target/release/libquicproquo_ffi.so` +- macOS: `target/release/libquicproquo_ffi.dylib` +- Windows: `target/release/quicproquo_ffi.dll` + +## Usage + +```bash +python examples/python/qpq_client.py \ + --server 127.0.0.1:7000 \ + --ca-cert server-cert.der \ + --server-name localhost \ + --username alice \ + --password secret +``` + +### Send a message + +```bash +python examples/python/qpq_client.py \ + --server 127.0.0.1:7000 \ + --ca-cert server-cert.der \ + --username alice --password secret \ + --send-to bob --message "hello from Python" +``` + +### Receive messages + +```bash +python examples/python/qpq_client.py \ + --server 127.0.0.1:7000 \ + --ca-cert server-cert.der \ + --username bob --password secret \ + --receive --timeout 10000 +``` + +## Library path + +The script auto-detects the library in `target/release/` or `target/debug/`. +Override with `QPQ_FFI_LIB`: + +```bash +export QPQ_FFI_LIB=/path/to/libquicproquo_ffi.so +``` + +## Programmatic usage + +```python +from qpq_client import QpqClient, _find_library, _load_library + +lib = _load_library(_find_library()) +with QpqClient(lib) as client: + client.connect("127.0.0.1:7000", "server-cert.der", "localhost") + client.login("alice", "secret") + client.send("bob", "hello") + messages = client.receive(timeout_ms=5000) +``` diff --git a/examples/python/qpq_client.py b/examples/python/qpq_client.py new file mode 100644 index 0000000..c61e81f --- /dev/null +++ b/examples/python/qpq_client.py @@ -0,0 +1,231 @@ +#!/usr/bin/env python3 +"""quicproquo Python client -- ctypes wrapper around libquicproquo_ffi. + +Usage: + python qpq_client.py --server 127.0.0.1:7000 \\ + --ca-cert server-cert.der \\ + --server-name localhost \\ + --username alice --password secret \\ + [--send-to bob --message "hello"] \\ + [--receive --timeout 5000] +""" + +from __future__ import annotations + +import argparse +import ctypes +import json +import os +import sys +from ctypes import ( + POINTER, + c_char_p, + c_int, + c_size_t, + c_uint32, + c_void_p, +) +from pathlib import Path + +# --------------------------------------------------------------------------- +# Status codes (must match quicproquo-ffi/src/lib.rs) +# --------------------------------------------------------------------------- +QPQ_OK = 0 +QPQ_ERROR = 1 +QPQ_AUTH_FAILED = 2 +QPQ_TIMEOUT = 3 +QPQ_NOT_CONNECTED = 4 + +STATUS_NAMES = { + QPQ_OK: "OK", + QPQ_ERROR: "ERROR", + QPQ_AUTH_FAILED: "AUTH_FAILED", + QPQ_TIMEOUT: "TIMEOUT", + QPQ_NOT_CONNECTED: "NOT_CONNECTED", +} + + +def _status_name(code: int) -> str: + return STATUS_NAMES.get(code, f"UNKNOWN({code})") + + +# --------------------------------------------------------------------------- +# Library loading +# --------------------------------------------------------------------------- + +def _find_library() -> str: + """Locate libquicproquo_ffi shared library.""" + env = os.environ.get("QPQ_FFI_LIB") + if env: + return env + + # Walk up from this script to find the cargo target directory. + repo_root = Path(__file__).resolve().parent.parent.parent + for profile in ("release", "debug"): + for ext in ("so", "dylib", "dll"): + candidate = repo_root / "target" / profile / f"libquicproquo_ffi.{ext}" + if candidate.exists(): + return str(candidate) + + print( + "ERROR: Could not find libquicproquo_ffi. " + "Build with: cargo build --release -p quicproquo-ffi\n" + "Or set QPQ_FFI_LIB=/path/to/libquicproquo_ffi.so", + file=sys.stderr, + ) + sys.exit(1) + + +def _load_library(path: str) -> ctypes.CDLL: + """Load the FFI library and declare function signatures.""" + lib = ctypes.cdll.LoadLibrary(path) + + # qpq_connect(server, ca_cert, server_name) -> *mut QpqHandle + lib.qpq_connect.argtypes = [c_char_p, c_char_p, c_char_p] + lib.qpq_connect.restype = c_void_p + + # qpq_login(handle, username, password) -> i32 + lib.qpq_login.argtypes = [c_void_p, c_char_p, c_char_p] + lib.qpq_login.restype = c_int + + # qpq_send(handle, recipient, message, message_len) -> i32 + lib.qpq_send.argtypes = [c_void_p, c_char_p, ctypes.POINTER(ctypes.c_uint8), c_size_t] + lib.qpq_send.restype = c_int + + # qpq_receive(handle, timeout_ms, out_json) -> i32 + lib.qpq_receive.argtypes = [c_void_p, c_uint32, POINTER(c_char_p)] + lib.qpq_receive.restype = c_int + + # qpq_disconnect(handle) + lib.qpq_disconnect.argtypes = [c_void_p] + lib.qpq_disconnect.restype = None + + # qpq_last_error(handle) -> *const c_char + lib.qpq_last_error.argtypes = [c_void_p] + lib.qpq_last_error.restype = c_char_p + + # qpq_free_string(ptr) + lib.qpq_free_string.argtypes = [c_char_p] + lib.qpq_free_string.restype = None + + return lib + + +# --------------------------------------------------------------------------- +# High-level wrapper +# --------------------------------------------------------------------------- + +class QpqClient: + """Synchronous Python client wrapping the quicproquo C FFI.""" + + def __init__(self, lib: ctypes.CDLL): + self._lib = lib + self._handle: int | None = None + + def connect(self, server: str, ca_cert: str, server_name: str) -> None: + handle = self._lib.qpq_connect( + server.encode(), ca_cert.encode(), server_name.encode(), + ) + if not handle: + raise ConnectionError("qpq_connect returned null -- check server address and CA cert") + self._handle = handle + + def login(self, username: str, password: str) -> None: + rc = self._lib.qpq_login(self._handle, username.encode(), password.encode()) + if rc != QPQ_OK: + err = self._last_error() + raise RuntimeError(f"qpq_login failed ({_status_name(rc)}): {err}") + + def send(self, recipient: str, message: str) -> None: + msg_bytes = message.encode() + buf = (ctypes.c_uint8 * len(msg_bytes))(*msg_bytes) + rc = self._lib.qpq_send(self._handle, recipient.encode(), buf, len(msg_bytes)) + if rc != QPQ_OK: + err = self._last_error() + raise RuntimeError(f"qpq_send failed ({_status_name(rc)}): {err}") + + def receive(self, timeout_ms: int = 5000) -> list[str]: + out = c_char_p() + rc = self._lib.qpq_receive(self._handle, c_uint32(timeout_ms), ctypes.byref(out)) + if rc == QPQ_TIMEOUT: + return [] + if rc != QPQ_OK: + err = self._last_error() + raise RuntimeError(f"qpq_receive failed ({_status_name(rc)}): {err}") + + result: list[str] = [] + if out.value: + result = json.loads(out.value.decode()) + self._lib.qpq_free_string(out) + return result + + def disconnect(self) -> None: + if self._handle: + self._lib.qpq_disconnect(self._handle) + self._handle = None + + def _last_error(self) -> str: + err = self._lib.qpq_last_error(self._handle) + if err: + return err.decode(errors="replace") + return "(no error message)" + + def __enter__(self): + return self + + def __exit__(self, *_): + self.disconnect() + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + +def main() -> None: + parser = argparse.ArgumentParser(description="quicproquo Python FFI client") + parser.add_argument("--server", required=True, help="Server address (host:port)") + parser.add_argument("--ca-cert", required=True, help="Path to CA certificate (DER)") + parser.add_argument("--server-name", default="localhost", help="TLS server name") + parser.add_argument("--username", required=True, help="OPAQUE username") + parser.add_argument("--password", required=True, help="OPAQUE password") + parser.add_argument("--send-to", help="Recipient username for sending a message") + parser.add_argument("--message", help="Message text to send") + parser.add_argument("--receive", action="store_true", help="Receive pending messages") + parser.add_argument("--timeout", type=int, default=5000, help="Receive timeout (ms)") + args = parser.parse_args() + + lib_path = _find_library() + print(f"Using library: {lib_path}") + lib = _load_library(lib_path) + + with QpqClient(lib) as client: + print(f"Connecting to {args.server}...") + client.connect(args.server, args.ca_cert, args.server_name) + print("Connected.") + + print(f"Logging in as {args.username}...") + client.login(args.username, args.password) + print("Logged in.") + + if args.send_to and args.message: + print(f"Sending to {args.send_to}: {args.message}") + client.send(args.send_to, args.message) + print("Sent.") + + if args.receive: + print(f"Receiving (timeout={args.timeout}ms)...") + messages = client.receive(timeout_ms=args.timeout) + if messages: + print(f"Received {len(messages)} message(s):") + for i, msg in enumerate(messages, 1): + print(f" [{i}] {msg}") + else: + print("No messages received.") + + print("Disconnecting...") + + print("Done.") + + +if __name__ == "__main__": + main()