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

12
Cargo.lock generated
View File

@@ -5481,6 +5481,7 @@ dependencies = [
"ciborium", "ciborium",
"criterion", "criterion",
"ed25519-dalek 2.2.0", "ed25519-dalek 2.2.0",
"getrandom 0.2.17",
"hkdf", "hkdf",
"hmac", "hmac",
"ml-kem", "ml-kem",
@@ -5501,6 +5502,17 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "quicproquo-ffi"
version = "0.1.0"
dependencies = [
"anyhow",
"hex",
"quicproquo-client",
"serde_json",
"tokio",
]
[[package]] [[package]]
name = "quicproquo-gen" name = "quicproquo-gen"
version = "0.1.0" version = "0.1.0"

View File

@@ -11,6 +11,7 @@ members = [
"crates/quicproquo-gen", "crates/quicproquo-gen",
"crates/quicproquo-gui", "crates/quicproquo-gui",
"crates/quicproquo-mobile", "crates/quicproquo-mobile",
"crates/quicproquo-ffi",
# P2P crate uses iroh (~90 extra deps). Kept in the workspace so it can be # 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 # referenced as an optional dependency; only compiled when the `mesh` feature
# is enabled on quicproquo-client. # is enabled on quicproquo-client.

132
README.md
View File

@@ -41,7 +41,7 @@ agreement across any number of participants. Messages are framed with
## Features ## Features
### Working ### Core
- **Interactive REPL** — multi-conversation chat with auto-register, auto-login, slash commands, background polling, and message history - **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 - **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 - **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) - **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 - **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 <username>` 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 ### REPL slash commands
| Command | Description | | Command | Description |
|---|---| |---|---|
| `/dm <username>` | Start a 1:1 DM with a peer | | `/dm <username>` | Start a 1:1 DM with a peer |
| `/create-group <name>` | Create a new group | | `/create-group <name>` (or `/cg`) | Create a new group |
| `/invite <username>` | Add a member to the current group | | `/invite <username>` | Add a member to the current group |
| `/remove <username>` | Remove a member from the current group |
| `/join` | Join a pending group invitation | | `/join` | Join a pending group invitation |
| `/leave` | Leave the current group |
| `/switch @user` or `/switch #group` | Switch active conversation | | `/switch @user` or `/switch #group` | Switch active conversation |
| `/list` or `/ls` | List all conversations | | `/list` or `/ls` | List all conversations |
| `/members` | Show group members | | `/members` | Show group members |
| `/history [count]` | Show message history (default 20) | | `/history [count]` (or `/hist`) | Show message history (default 20) |
| `/verify <username>` | 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 <host:port>` | Note a discovered server address |
| `/whoami` | Show identity and group status | | `/whoami` | Show identity and group status |
| `/help` | Command reference | | `/help` | Command reference |
| `/quit` | Exit | | `/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 - **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) - **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 brew install capnp # macOS
# apt-get install capnproto # Debian/Ubuntu # apt-get install capnproto # Debian/Ubuntu
# Build and test # Build (excludes GUI — requires GTK system libs)
cargo build --workspace cargo build --bin qpq-server --bin qpq
cargo test --workspace
# Start the server (port 7000 by default) # Run tests
cargo run --bin qpq-server cargo test --workspace --exclude quicproquo-gui
# Run the two-party demo # Start the server (port 7000 by default, auto-generates self-signed cert)
cargo run --bin qpq -- demo-group --server 127.0.0.1:7000 cargo run --bin qpq-server -- --allow-insecure-auth
# Interactive REPL (auto-registers and logs in) # Interactive REPL (auto-registers and logs in)
cargo run --bin qpq -- repl --username alice --password mypass cargo run --bin qpq -- repl --username alice --password mypass
@@ -122,15 +135,23 @@ listen = "0.0.0.0:7000"
data_dir = "data" data_dir = "data"
tls_cert = "data/server-cert.der" tls_cert = "data/server-cert.der"
tls_key = "data/server-key.der" tls_key = "data/server-key.der"
auth_token = "devtoken" auth_token = "your-strong-token-here"
store_backend = "sql" # or "file" store_backend = "sql" # or "file"
db_path = "data/qpq.db" 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 EOF
cargo run --bin qpq-server -- --config qpq-server.toml 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. 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-core` | MLS group operations, hybrid KEM, OPAQUE auth, crypto primitives |
| `quicproquo-proto` | Cap'n Proto schemas and generated RPC code | | `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-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-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 | | 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 | | 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 | | 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 | | 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 | | 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. 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 ## 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 | Phase | Focus | Status |
- **Full MLS lifecycle** — member removal, credential updates, proposal handling |-------|-------|--------|
- **CI pipeline** — GitHub Actions (test, clippy, fmt, audit) | 1 | Production hardening (unwrap removal, secure defaults, Docker) | In progress |
- **Accounts & devices model** — per-account rate limits, multi-device support | 2 | Test and CI maturity | Partially done |
- **Client offline queue** — idempotent message IDs, gap detection, retry | 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) - **Federation routing** — server-to-server message relay with mTLS
- CA-signed TLS / Let's Encrypt support - **mDNS discovery** — servers advertise on local network, clients discover peers
- HTTP health endpoint for load balancers - **P2P transport** — iroh-based direct messaging re-included in workspace (`--features mesh`)
- Connection draining and graceful shutdown - **CI pipeline** — fmt, build, test, clippy, deny, audit, coverage, Docker build
- Wire versioning and N-1 compatibility - **Plugin system** — dynamic `.so`/`.dylib` loading with C-compatible hook API
- **Safety numbers** — Signal-style 60-digit verification codes
### Research - **Transcript export** — encrypted, hash-chained message archives
- 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
--- ---
## Building without the GUI ## Building without the GUI
The GUI crate requires GTK system libraries. To build just the server and client:
```bash ```bash
cargo build --bin qpq-server --bin qpq 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. - Local databases are encrypted with SQLCipher when a password is provided.
- Session tokens are encrypted at rest (Argon2id key derivation + ChaCha20-Poly1305). - 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. - **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.
--- ---

View File

@@ -5,8 +5,25 @@ edition = "2021"
description = "Crypto primitives, MLS state machine, and hybrid post-quantum KEM for quicproquo." description = "Crypto primitives, MLS state machine, and hybrid post-quantum KEM for quicproquo."
license = "MIT" 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] [dependencies]
# Crypto — classical # Crypto — classical (always available, WASM-safe)
x25519-dalek = { workspace = true } x25519-dalek = { workspace = true }
ed25519-dalek = { workspace = true } ed25519-dalek = { workspace = true }
sha2 = { workspace = true } sha2 = { workspace = true }
@@ -16,32 +33,34 @@ ciborium = { workspace = true }
chacha20poly1305 = { workspace = true } chacha20poly1305 = { workspace = true }
zeroize = { workspace = true } zeroize = { workspace = true }
rand = { 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 } ml-kem = { workspace = true }
# Crypto — OPAQUE password-authenticated key exchange # Crypto — OPAQUE password-authenticated key exchange (native only)
opaque-ke = { workspace = true } opaque-ke = { workspace = true, optional = true }
argon2 = { workspace = true }
# Crypto — MLS (M2) # Crypto — MLS (M2) (native only)
openmls = { workspace = true } openmls = { workspace = true, optional = true }
openmls_rust_crypto = { workspace = true } openmls_rust_crypto = { workspace = true, optional = true }
openmls_traits = { workspace = true } openmls_traits = { workspace = true, optional = true }
tls_codec = { workspace = true } tls_codec = { workspace = true, optional = true }
serde = { workspace = true } bincode = { workspace = true, optional = true }
bincode = { workspace = true }
serde_json = { workspace = true }
# Serialisation # Serialisation (native only)
capnp = { workspace = true } capnp = { workspace = true, optional = true }
quicproquo-proto = { path = "../quicproquo-proto" } quicproquo-proto = { path = "../quicproquo-proto", optional = true }
# Async runtime # Async runtime (native only)
tokio = { workspace = true } tokio = { workspace = true, optional = true }
# Error handling # WASM: provide getrandom with js backend
thiserror = { workspace = true } [target.'cfg(target_arch = "wasm32")'.dependencies]
getrandom = { version = "0.2", features = ["js"] }
[lints] [lints]
workspace = true workspace = true

View File

@@ -6,6 +6,7 @@ use thiserror::Error;
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum CoreError { pub enum CoreError {
/// Cap'n Proto serialisation or deserialisation failed. /// Cap'n Proto serialisation or deserialisation failed.
#[cfg(feature = "native")]
#[error("Cap'n Proto error: {0}")] #[error("Cap'n Proto error: {0}")]
Capnp(#[from] capnp::Error), Capnp(#[from] capnp::Error),

View File

@@ -17,8 +17,6 @@
//! collision-resistant identifier for logging. //! collision-resistant identifier for logging.
use ed25519_dalek::{Signer as DalekSigner, SigningKey, VerifyingKey}; 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 serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use zeroize::Zeroizing; use zeroize::Zeroizing;
@@ -87,15 +85,16 @@ impl IdentityKeypair {
/// Implement the openmls `Signer` trait so `IdentityKeypair` can be passed /// Implement the openmls `Signer` trait so `IdentityKeypair` can be passed
/// directly to `KeyPackage::builder().build(...)` without needing the external /// directly to `KeyPackage::builder().build(...)` without needing the external
/// `openmls_basic_credential` crate. /// `openmls_basic_credential` crate.
impl Signer for IdentityKeypair { #[cfg(feature = "native")]
fn sign(&self, payload: &[u8]) -> Result<Vec<u8>, MlsError> { impl openmls_traits::signatures::Signer for IdentityKeypair {
fn sign(&self, payload: &[u8]) -> Result<Vec<u8>, openmls_traits::types::Error> {
let sk = self.signing_key(); let sk = self.signing_key();
let sig: ed25519_dalek::Signature = sk.sign(payload); let sig: ed25519_dalek::Signature = sk.sign(payload);
Ok(sig.to_bytes().to_vec()) Ok(sig.to_bytes().to_vec())
} }
fn signature_scheme(&self) -> SignatureScheme { fn signature_scheme(&self) -> openmls_traits::types::SignatureScheme {
SignatureScheme::ED25519 openmls_traits::types::SignatureScheme::ED25519
} }
} }

View File

@@ -1,6 +1,28 @@
//! Core cryptographic primitives, MLS group state machine, and hybrid //! Core cryptographic primitives, MLS group state machine, and hybrid
//! post-quantum KEM for quicproquo. //! 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 layout
//! //!
//! | Module | Responsibility | //! | Module | Responsibility |
@@ -15,36 +37,50 @@
mod app_message; mod app_message;
mod error; mod error;
mod group;
mod hybrid_crypto;
mod hybrid_kem; mod hybrid_kem;
mod identity; mod identity;
mod keypackage;
mod keystore;
pub mod opaque_auth;
pub mod padding; pub mod padding;
pub mod safety_numbers; pub mod safety_numbers;
pub mod sealed_sender; pub mod sealed_sender;
pub mod transcript; 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::{ pub use app_message::{
serialize, serialize_chat, serialize_reaction, serialize_read_receipt, serialize_reply, serialize, serialize_chat, serialize_reaction, serialize_read_receipt, serialize_reply,
serialize_typing, parse, generate_message_id, AppMessage, MessageType, VERSION as APP_MESSAGE_VERSION, serialize_typing, parse, generate_message_id, AppMessage, MessageType, VERSION as APP_MESSAGE_VERSION,
}; };
pub use error::CoreError; pub use error::CoreError;
pub use group::{GroupMember, ReceivedMessage, ReceivedMessageWithSender};
pub use hybrid_kem::{ pub use hybrid_kem::{
hybrid_decrypt, hybrid_encrypt, HybridKemError, HybridKeypair, HybridKeypairBytes, hybrid_decrypt, hybrid_encrypt, HybridKemError, HybridKeypair, HybridKeypairBytes,
HybridPublicKey, HybridPublicKey,
}; };
pub use hybrid_crypto::{HybridCrypto, HybridCryptoProvider};
pub use identity::{verify_delivery_proof, IdentityKeypair}; 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 safety_numbers::compute_safety_number;
pub use transcript::{ pub use transcript::{
read_transcript, verify_transcript_chain, ChainVerdict, DecodedRecord, TranscriptRecord, read_transcript, verify_transcript_chain, ChainVerdict, DecodedRecord, TranscriptRecord,
TranscriptWriter, 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;

View File

@@ -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

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

View File

@@ -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-plugin-api/Cargo.toml crates/quicproquo-plugin-api/Cargo.toml
COPY crates/quicproquo-gui/Cargo.toml crates/quicproquo-gui/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-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 # Create dummy source files so `cargo build` can resolve the dependency graph
# and cache the compiled dependencies before copying real source. # and cache the compiled dependencies before copying real source.
@@ -38,6 +39,7 @@ RUN mkdir -p \
crates/quicproquo-plugin-api/src \ crates/quicproquo-plugin-api/src \
crates/quicproquo-gui/src \ crates/quicproquo-gui/src \
crates/quicproquo-mobile/src \ crates/quicproquo-mobile/src \
crates/quicproquo-ffi/src \
&& echo 'fn main() {}' > crates/quicproquo-server/src/main.rs \ && echo 'fn main() {}' > crates/quicproquo-server/src/main.rs \
&& echo 'fn main() {}' > crates/quicproquo-client/src/main.rs \ && echo 'fn main() {}' > crates/quicproquo-client/src/main.rs \
&& echo 'fn main() {}' > crates/quicproquo-gen/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-kt/src/lib.rs \
&& touch crates/quicproquo-plugin-api/src/lib.rs \ && touch crates/quicproquo-plugin-api/src/lib.rs \
&& touch crates/quicproquo-gui/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. # Schemas must exist before the proto crate's build.rs runs.
COPY schemas/ schemas/ COPY schemas/ schemas/

View File

@@ -21,6 +21,9 @@
- [Certificate Lifecycle and CA-Signed TLS](getting-started/certificate-lifecycle.md) - [Certificate Lifecycle and CA-Signed TLS](getting-started/certificate-lifecycle.md)
- [Docker Deployment](getting-started/docker.md) - [Docker Deployment](getting-started/docker.md)
- [Bot SDK](getting-started/bot-sdk.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) - [Demo Walkthrough: Alice and Bob](getting-started/demo-walkthrough.md)
--- ---

View File

@@ -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 <stdio.h>
#include <string.h>
/* 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.

View File

@@ -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 -- <subcommand>
```
## Subcommands
### `qpq-gen plugin <name>` -- 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 <name>` -- 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 <text>` | 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` | `<bot-name>` |
| `QPQ_PASSWORD` | `changeme` |
| `QPQ_CA_CERT` | `server-cert.der` |
| `QPQ_STATE_PATH` | `<bot-name>-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 <Name>` -- 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/<name>.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 <name>` to let plugins observe
the new RPC
6. **Verify** -- `cargo build -p quicproquo-server && cargo test -p quicproquo-server`
### `qpq-gen hook <name>` -- 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`

View File

@@ -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.

68
examples/python/README.md Normal file
View File

@@ -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)
```

View File

@@ -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()