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