feat: Phase 9 — developer experience, extensibility, and community growth
New crates: - quicproquo-bot: Bot SDK with polling API + JSON pipe mode - quicproquo-kt: Key Transparency Merkle log (RFC 9162 subset) - quicproquo-plugin-api: no_std C-compatible plugin vtable API - quicproquo-gen: scaffolding tool (qpq-gen plugin/bot/rpc/hook) Server features: - ServerHooks trait wired into all RPC handlers (enqueue, fetch, auth, channel, registration) with plugin rejection support - Dynamic plugin loader (libloading) with --plugin-dir config - Delivery proof canary tokens (Ed25519 server signatures on enqueue) - Key Transparency Merkle log with inclusion proofs on resolveUser Core library: - Safety numbers (60-digit HMAC-SHA256 key verification codes) - Verifiable transcript archive (CBOR + ChaCha20-Poly1305 + hash chain) - Delivery proof verification utility - Criterion benchmarks (hybrid KEM, MLS, identity, sealed sender, padding) Client: - /verify REPL command for out-of-band key verification - Full-screen TUI via Ratatui (feature-gated --features tui) - qpq export / qpq export-verify CLI subcommands - KT inclusion proof verification on user resolution Also: ROADMAP Phase 9 added, bot SDK docs, server hooks docs, crate-responsibilities updated, example plugins (rate_limit, logging).
This commit is contained in:
1
examples/plugins/logging_plugin/.gitignore
vendored
Normal file
1
examples/plugins/logging_plugin/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
target/
|
||||
15
examples/plugins/logging_plugin/Cargo.toml
Normal file
15
examples/plugins/logging_plugin/Cargo.toml
Normal file
@@ -0,0 +1,15 @@
|
||||
# This is a standalone cdylib crate outside the main workspace.
|
||||
[workspace]
|
||||
|
||||
[package]
|
||||
name = "logging_plugin"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Reference quicproquo server plugin: logs all hook events to stderr."
|
||||
license = "MIT"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
quicproquo-plugin-api = { path = "../../../crates/quicproquo-plugin-api" }
|
||||
162
examples/plugins/logging_plugin/src/lib.rs
Normal file
162
examples/plugins/logging_plugin/src/lib.rs
Normal file
@@ -0,0 +1,162 @@
|
||||
//! Reference quicproquo server plugin: logs all hook events to stderr.
|
||||
//!
|
||||
//! This plugin demonstrates every hook point in the `HookVTable` API. It
|
||||
//! writes a single-line human-readable record to stderr for each server event.
|
||||
//! No state is required, so `user_data` is left null.
|
||||
//!
|
||||
//! # Building
|
||||
//!
|
||||
//! ```bash
|
||||
//! cargo build --release -p logging_plugin
|
||||
//! # Output: target/release/liblogging_plugin.so (Linux)
|
||||
//! # target/release/liblogging_plugin.dylib (macOS)
|
||||
//! ```
|
||||
//!
|
||||
//! # Deploying
|
||||
//!
|
||||
//! ```bash
|
||||
//! cp target/release/liblogging_plugin.so /etc/qpq/plugins/
|
||||
//! qpq-server --plugin-dir /etc/qpq/plugins
|
||||
//! ```
|
||||
|
||||
use std::ffi::c_void;
|
||||
use std::slice;
|
||||
|
||||
use quicproquo_plugin_api::{
|
||||
CAuthEvent, CChannelEvent, CFetchEvent, CMessageEvent, HookVTable, HOOK_CONTINUE, PLUGIN_OK,
|
||||
};
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
fn hex_prefix(ptr: *const u8, len: usize) -> String {
|
||||
if ptr.is_null() || len == 0 {
|
||||
return "(none)".to_string();
|
||||
}
|
||||
let bytes = unsafe { slice::from_raw_parts(ptr, len.min(4)) };
|
||||
bytes.iter().map(|b| format!("{:02x}", b)).collect()
|
||||
}
|
||||
|
||||
fn str_from_raw(ptr: *const u8, len: usize) -> &'static str {
|
||||
if ptr.is_null() || len == 0 {
|
||||
return "";
|
||||
}
|
||||
// Safety: the server owns the memory and it remains valid for the callback duration.
|
||||
let bytes = unsafe { slice::from_raw_parts(ptr, len) };
|
||||
std::str::from_utf8(bytes).unwrap_or("<invalid utf8>")
|
||||
}
|
||||
|
||||
// ── Hook callbacks ────────────────────────────────────────────────────────────
|
||||
|
||||
unsafe extern "C" fn on_message_enqueue(
|
||||
_user_data: *mut c_void,
|
||||
event: *const CMessageEvent,
|
||||
) -> i32 {
|
||||
let e = &*event;
|
||||
eprintln!(
|
||||
"[qpq-plugin:logging] enqueue: recipient={} payload_len={} seq={} has_sender={}",
|
||||
hex_prefix(e.recipient_key, e.recipient_key_len),
|
||||
e.payload_len,
|
||||
e.seq,
|
||||
!e.sender_identity.is_null(),
|
||||
);
|
||||
HOOK_CONTINUE
|
||||
}
|
||||
|
||||
unsafe extern "C" fn on_batch_enqueue(
|
||||
_user_data: *mut c_void,
|
||||
events: *const CMessageEvent,
|
||||
count: usize,
|
||||
) {
|
||||
eprintln!("[qpq-plugin:logging] batch_enqueue: count={}", count);
|
||||
let events = slice::from_raw_parts(events, count);
|
||||
for (i, e) in events.iter().enumerate() {
|
||||
eprintln!(
|
||||
"[qpq-plugin:logging] [{}/{}] recipient={} seq={}",
|
||||
i + 1,
|
||||
count,
|
||||
hex_prefix(e.recipient_key, e.recipient_key_len),
|
||||
e.seq,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
unsafe extern "C" fn on_auth(_user_data: *mut c_void, event: *const CAuthEvent) {
|
||||
let e = &*event;
|
||||
let username = str_from_raw(e.username, e.username_len);
|
||||
if e.success != 0 {
|
||||
eprintln!("[qpq-plugin:logging] auth: user='{}' SUCCESS", username);
|
||||
} else {
|
||||
let reason = str_from_raw(e.failure_reason, e.failure_reason_len);
|
||||
eprintln!(
|
||||
"[qpq-plugin:logging] auth: user='{}' FAILURE reason='{}'",
|
||||
username, reason
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
unsafe extern "C" fn on_channel_created(
|
||||
_user_data: *mut c_void,
|
||||
event: *const CChannelEvent,
|
||||
) {
|
||||
let e = &*event;
|
||||
eprintln!(
|
||||
"[qpq-plugin:logging] channel_created: channel={} was_new={} initiator={}",
|
||||
hex_prefix(e.channel_id, e.channel_id_len),
|
||||
e.was_new != 0,
|
||||
hex_prefix(e.initiator_key, e.initiator_key_len),
|
||||
);
|
||||
}
|
||||
|
||||
unsafe extern "C" fn on_fetch(_user_data: *mut c_void, event: *const CFetchEvent) {
|
||||
let e = &*event;
|
||||
if e.message_count > 0 {
|
||||
eprintln!(
|
||||
"[qpq-plugin:logging] fetch: recipient={} count={}",
|
||||
hex_prefix(e.recipient_key, e.recipient_key_len),
|
||||
e.message_count,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
unsafe extern "C" fn on_user_registered(
|
||||
_user_data: *mut c_void,
|
||||
username: *const u8,
|
||||
username_len: usize,
|
||||
identity_key: *const u8,
|
||||
identity_key_len: usize,
|
||||
) {
|
||||
let name = str_from_raw(username, username_len);
|
||||
eprintln!(
|
||||
"[qpq-plugin:logging] user_registered: user='{}' key={}",
|
||||
name,
|
||||
hex_prefix(identity_key, identity_key_len),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Plugin entry point ────────────────────────────────────────────────────────
|
||||
|
||||
/// Called by the server once at startup.
|
||||
///
|
||||
/// # Safety
|
||||
///
|
||||
/// `vtable` must point to a zeroed `HookVTable` as provided by `qpq-server`.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn qpq_plugin_init(vtable: *mut HookVTable) -> i32 {
|
||||
if vtable.is_null() {
|
||||
return -1;
|
||||
}
|
||||
let v = &mut *vtable;
|
||||
|
||||
// user_data is not needed — all callbacks are stateless.
|
||||
v.user_data = std::ptr::null_mut();
|
||||
v.on_message_enqueue = Some(on_message_enqueue);
|
||||
v.on_batch_enqueue = Some(on_batch_enqueue);
|
||||
v.on_auth = Some(on_auth);
|
||||
v.on_channel_created = Some(on_channel_created);
|
||||
v.on_fetch = Some(on_fetch);
|
||||
v.on_user_registered = Some(on_user_registered);
|
||||
// error_message and destroy not needed (no state, never rejects).
|
||||
|
||||
eprintln!("[qpq-plugin:logging] initialized");
|
||||
PLUGIN_OK
|
||||
}
|
||||
1
examples/plugins/rate_limit_plugin/.gitignore
vendored
Normal file
1
examples/plugins/rate_limit_plugin/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
target/
|
||||
17
examples/plugins/rate_limit_plugin/Cargo.toml
Normal file
17
examples/plugins/rate_limit_plugin/Cargo.toml
Normal file
@@ -0,0 +1,17 @@
|
||||
# This is a standalone cdylib crate outside the main workspace.
|
||||
# It depends on quicproquo-plugin-api via a relative path.
|
||||
[workspace]
|
||||
|
||||
[package]
|
||||
name = "rate_limit_plugin"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Reference quicproquo server plugin: per-recipient payload-size rate limiter."
|
||||
license = "MIT"
|
||||
|
||||
# Compile as a shared library (.so / .dylib) for dynamic loading by qpq-server.
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
quicproquo-plugin-api = { path = "../../../crates/quicproquo-plugin-api" }
|
||||
108
examples/plugins/rate_limit_plugin/src/lib.rs
Normal file
108
examples/plugins/rate_limit_plugin/src/lib.rs
Normal file
@@ -0,0 +1,108 @@
|
||||
//! Reference quicproquo server plugin: payload-size rate limiter.
|
||||
//!
|
||||
//! Rejects any single message whose payload exceeds `MAX_PAYLOAD_BYTES`. In a
|
||||
//! real deployment you would extend this with per-sender token-bucket logic,
|
||||
//! but this example intentionally stays simple so the plugin API surface is
|
||||
//! easy to follow.
|
||||
//!
|
||||
//! # Building
|
||||
//!
|
||||
//! ```bash
|
||||
//! cargo build --release -p rate_limit_plugin
|
||||
//! # Output: target/release/librate_limit_plugin.so (Linux)
|
||||
//! # target/release/librate_limit_plugin.dylib (macOS)
|
||||
//! ```
|
||||
//!
|
||||
//! # Deploying
|
||||
//!
|
||||
//! Copy the resulting `.so` / `.dylib` into your plugin directory and start
|
||||
//! the server with `--plugin-dir /path/to/plugins`.
|
||||
//!
|
||||
//! # Config (via TOML)
|
||||
//!
|
||||
//! ```toml
|
||||
//! plugin_dir = "/etc/qpq/plugins"
|
||||
//! ```
|
||||
|
||||
use std::ffi::c_void;
|
||||
|
||||
use quicproquo_plugin_api::{CMessageEvent, HookVTable, HOOK_CONTINUE, HOOK_REJECT, PLUGIN_OK};
|
||||
|
||||
/// Maximum allowed encrypted payload size in bytes.
|
||||
const MAX_PAYLOAD_BYTES: usize = 512 * 1024; // 512 KiB
|
||||
|
||||
/// Plugin-private state, heap-allocated and passed as `user_data`.
|
||||
struct PluginState {
|
||||
/// Last rejection reason as a null-terminated C string owned by us.
|
||||
last_error: Option<std::ffi::CString>,
|
||||
}
|
||||
|
||||
impl PluginState {
|
||||
fn new() -> *mut Self {
|
||||
Box::into_raw(Box::new(Self { last_error: None }))
|
||||
}
|
||||
|
||||
fn set_error(&mut self, msg: &str) {
|
||||
// Replace previous error (if any); CString silently truncates interior NUL bytes,
|
||||
// but our format strings never contain them.
|
||||
self.last_error = std::ffi::CString::new(msg).ok();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Hook callbacks ────────────────────────────────────────────────────────────
|
||||
|
||||
unsafe extern "C" fn on_message_enqueue(
|
||||
user_data: *mut c_void,
|
||||
event: *const CMessageEvent,
|
||||
) -> i32 {
|
||||
let state = &mut *(user_data as *mut PluginState);
|
||||
let payload_len = (*event).payload_len;
|
||||
|
||||
if payload_len > MAX_PAYLOAD_BYTES {
|
||||
state.set_error(&format!(
|
||||
"payload {} bytes exceeds limit {} bytes",
|
||||
payload_len, MAX_PAYLOAD_BYTES
|
||||
));
|
||||
return HOOK_REJECT;
|
||||
}
|
||||
|
||||
HOOK_CONTINUE
|
||||
}
|
||||
|
||||
unsafe extern "C" fn error_message(user_data: *mut c_void) -> *const u8 {
|
||||
let state = &*(user_data as *mut PluginState);
|
||||
match &state.last_error {
|
||||
Some(s) => s.as_ptr() as *const u8,
|
||||
None => std::ptr::null(),
|
||||
}
|
||||
}
|
||||
|
||||
unsafe extern "C" fn destroy(user_data: *mut c_void) {
|
||||
if !user_data.is_null() {
|
||||
drop(Box::from_raw(user_data as *mut PluginState));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Plugin entry point ────────────────────────────────────────────────────────
|
||||
|
||||
/// Called by the server once at startup. Fill `vtable` with function pointers
|
||||
/// and return `PLUGIN_OK` (0) on success.
|
||||
///
|
||||
/// # Safety
|
||||
///
|
||||
/// `vtable` must be a valid pointer to a zeroed `HookVTable` as provided by
|
||||
/// `qpq-server`. Do not call from any other context.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn qpq_plugin_init(vtable: *mut HookVTable) -> i32 {
|
||||
if vtable.is_null() {
|
||||
return -1;
|
||||
}
|
||||
let vtable = &mut *vtable;
|
||||
|
||||
vtable.user_data = PluginState::new() as *mut c_void;
|
||||
vtable.on_message_enqueue = Some(on_message_enqueue);
|
||||
vtable.error_message = Some(error_message);
|
||||
vtable.destroy = Some(destroy);
|
||||
|
||||
PLUGIN_OK
|
||||
}
|
||||
Reference in New Issue
Block a user