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:
2026-03-03 22:47:38 +01:00
parent b6483dedbc
commit dc4e4e49a0
62 changed files with 6959 additions and 62 deletions

View File

@@ -19,6 +19,7 @@
- [Running the Client](getting-started/running-the-client.md)
- [Certificate Lifecycle and CA-Signed TLS](getting-started/certificate-lifecycle.md)
- [Docker Deployment](getting-started/docker.md)
- [Bot SDK](getting-started/bot-sdk.md)
- [Demo Walkthrough: Alice and Bob](getting-started/demo-walkthrough.md)
---
@@ -82,6 +83,7 @@
- [Delivery Service Internals](internals/delivery-service.md)
- [Authentication Service Internals](internals/authentication-service.md)
- [Storage Backend](internals/storage-backend.md)
- [Server Hooks (Plugin System)](internals/server-hooks.md)
---

View File

@@ -200,6 +200,39 @@ group state to disk.
---
## quicproquo-bot
**Role:** High-level SDK for building automated agents (bots) on the
quicproquo network. Wraps the client library into a simple polling-based API.
### Components
| Component | Description |
|------------------|-------------|
| `BotConfig` | Builder-pattern configuration: server address, credentials, TLS, state file path. |
| `Bot` | Connected bot instance. Methods: `connect()`, `send_dm()`, `receive()`, `receive_raw()`, `resolve_user()`. |
| `Message` | Received message struct with `sender`, `text`, and `seq` fields. |
| `run_pipe_mode` | JSON-lines stdin/stdout interface for shell integration (`send`, `recv`, `resolve` actions). |
### Architecture
Each `send_dm` and `receive` call opens a fresh QUIC connection (stateless
reconnect pattern). The bot wraps the client's `cmd_send` and
`receive_pending_plaintexts` functions, handling MLS group state internally.
### What this crate does NOT do
- No server-side logic.
- No raw MLS operations — delegates to `quicproquo-client` high-level functions.
- No persistent QUIC connections — each operation reconnects.
### Key dependencies
`quicproquo-core`, `quicproquo-client`, `tokio`, `anyhow`, `tracing`,
`serde`, `serde_json`, `hex`.
---
## Other workspace crates
| Crate | Role |

View File

@@ -0,0 +1,233 @@
# Bot SDK
The `quicproquo-bot` crate provides a high-level SDK for building automated
agents on the quicproquo network. Bots authenticate with OPAQUE, send and
receive E2E encrypted messages through MLS, and can be driven programmatically
or via a JSON pipe interface for shell integration.
---
## Adding the dependency
```toml
[dependencies]
quicproquo-bot = { path = "../crates/quicproquo-bot" }
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
anyhow = "1"
```
---
## Quick start
```rust,no_run
use quicproquo_bot::{Bot, BotConfig};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let config = BotConfig::new("127.0.0.1:7000", "bot-user", "bot-password")
.ca_cert("server-cert.der")
.state_path("bot-state.bin");
let bot = Bot::connect(config).await?;
// Send a DM
bot.send_dm("alice", "Hello from bot!").await?;
// Poll for messages
loop {
for msg in bot.receive(5000).await? {
println!("{}: {}", msg.sender, msg.text);
if msg.text.starts_with("!echo ") {
bot.send_dm(&msg.sender, &msg.text[6..]).await?;
}
}
}
}
```
---
## Configuration
`BotConfig` uses a builder pattern. The only required arguments are the server
address, username, and password:
```rust,no_run
# use quicproquo_bot::BotConfig;
let config = BotConfig::new("127.0.0.1:7000", "my-bot", "secret123")
.ca_cert("certs/server-cert.der") // TLS CA certificate (DER format)
.server_name("my-server.example") // TLS SNI (default: "localhost")
.state_path("my-bot-state.bin") // Persistent state file
.state_password("encrypt-me") // State file encryption password
.device_id("bot-device-1"); // Device identifier
```
| Method | Default | Description |
|-------------------|-----------------------|-------------|
| `ca_cert()` | `"server-cert.der"` | Path to the server's CA certificate in DER format. |
| `server_name()` | `"localhost"` | TLS server name for certificate validation. |
| `state_path()` | `"bot-state.bin"` | Path to the bot's encrypted state file. |
| `state_password()` | None (unencrypted) | Password for encrypting the state file at rest. |
| `device_id()` | None | Device ID reported to the server in auth tokens. |
---
## Sending messages
```rust,no_run
# use quicproquo_bot::Bot;
# async fn example(bot: &Bot) -> anyhow::Result<()> {
// Send a plaintext DM — encryption is handled internally via MLS
bot.send_dm("alice", "Hello!").await?;
# Ok(())
# }
```
`send_dm` resolves the username, establishes or joins the MLS group for the DM
channel, encrypts the plaintext, and delivers it through the server. Each call
opens a fresh QUIC connection (stateless reconnect pattern).
---
## Receiving messages
```rust,no_run
# use quicproquo_bot::Bot;
# async fn example(bot: &Bot) -> anyhow::Result<()> {
// Wait up to 5 seconds for pending messages
let messages = bot.receive(5000).await?;
for msg in &messages {
println!("[seq={}] {}: {}", msg.seq, msg.sender, msg.text);
}
// For binary/non-UTF-8 content, use receive_raw
let raw_messages = bot.receive_raw(5000).await?;
for payload in &raw_messages {
println!("received {} bytes", payload.len());
}
# Ok(())
# }
```
The `Message` struct contains:
| Field | Type | Description |
|----------|----------|-------------|
| `sender` | `String` | The sender's username. |
| `text` | `String` | Decrypted plaintext content (UTF-8). |
| `seq` | `u64` | Sequence number. |
---
## Resolving users
```rust,no_run
# use quicproquo_bot::Bot;
# async fn example(bot: &Bot) -> anyhow::Result<()> {
let identity_key = bot.resolve_user("alice").await?;
println!("alice's identity key: {} bytes", identity_key.len());
# Ok(())
# }
```
---
## Identity inspection
```rust,no_run
# use quicproquo_bot::Bot;
# fn example(bot: &Bot) {
println!("username: {}", bot.username());
println!("identity key (hex): {}", bot.identity_key_hex());
let raw_key: [u8; 32] = bot.identity_key();
# }
```
---
## Pipe mode (stdin/stdout JSON lines)
For shell integration, the bot SDK supports a JSON-lines pipe interface. Each
line on stdin is a JSON command; results are written to stdout as JSON lines.
### Supported actions
**Send a message:**
```json
{"action": "send", "to": "alice", "text": "hello from pipe"}
```
Response:
```json
{"status": "ok", "action": "send"}
```
**Receive pending messages:**
```json
{"action": "recv", "timeout_ms": 5000}
```
Response:
```json
{"status": "ok", "messages": [{"sender": "peer", "text": "hi", "seq": 0}]}
```
**Resolve a username:**
```json
{"action": "resolve", "username": "alice"}
```
Response:
```json
{"status": "ok", "identity_key": "ab12cd34..."}
```
### Error responses
All actions return an error object on failure:
```json
{"error": "OPAQUE login: connection refused"}
```
### Shell examples
```bash
# Send via pipe
echo '{"action":"send","to":"alice","text":"hello"}' | my-bot-binary
# Receive via pipe
echo '{"action":"recv","timeout_ms":5000}' | my-bot-binary
# Use with jq for pretty output
echo '{"action":"recv","timeout_ms":3000}' | my-bot-binary | jq .
```
---
## Architecture notes
- **Stateless reconnect**: Each `send_dm` and `receive` call opens a fresh QUIC
connection. There is no persistent connection to manage.
- **MLS encryption**: All messages are end-to-end encrypted via MLS (RFC 9420).
The bot SDK wraps the client library's `cmd_send` and
`receive_pending_plaintexts` functions.
- **State persistence**: The bot's identity seed and MLS group state are stored
in the state file. Losing this file means losing the bot's identity.
- **Cap'n Proto !Send**: RPC calls run on a `tokio::task::LocalSet` because
`capnp-rpc` is `!Send`.
---
## Next steps
- [Running the Client](running-the-client.md) -- CLI subcommands and REPL
- [Server Hooks](../internals/server-hooks.md) -- extend the server with plugins
- [Demo Walkthrough](demo-walkthrough.md) -- step-by-step messaging scenario

View File

@@ -0,0 +1,259 @@
# Server Hooks
The `ServerHooks` trait provides a plugin system for extending the quicproquo
server. Hooks fire at key points in the request lifecycle — message delivery,
authentication, channel creation, and message fetch — allowing you to inspect,
log, rate-limit, or reject operations without modifying server internals.
---
## Overview
```text
Client RPC request
└─ Validation (auth, rate limits, wire format)
└─ Hook fires (on_message_enqueue, on_auth, etc.)
├─ HookAction::Continue → proceed to storage/delivery
└─ HookAction::Reject("reason") → error returned to client
```
Hooks are called **synchronously** in the RPC handler path after validation
but before storage. Keep hook implementations fast — offload heavy work
(HTTP calls, disk I/O, analytics) to background tasks.
---
## The `ServerHooks` trait
```rust,ignore
pub trait ServerHooks: Send + Sync {
/// Called before a message is stored in the delivery queue.
/// Return HookAction::Reject to prevent delivery.
fn on_message_enqueue(&self, event: &MessageEvent) -> HookAction {
HookAction::Continue
}
/// Called after a batch of messages is enqueued.
fn on_batch_enqueue(&self, events: &[MessageEvent]) {}
/// Called after a successful or failed login attempt.
fn on_auth(&self, event: &AuthEvent) {}
/// Called after a channel is created or looked up.
fn on_channel_created(&self, event: &ChannelEvent) {}
/// Called after messages are fetched from the delivery queue.
fn on_fetch(&self, event: &FetchEvent) {}
/// Called when a user completes OPAQUE registration.
fn on_user_registered(&self, username: &str, identity_key: &[u8]) {}
}
```
All methods have default no-op implementations. Override only the events you
care about.
---
## Hook action
```rust,ignore
pub enum HookAction {
/// Allow the operation to proceed.
Continue,
/// Reject the operation with a reason (returned to the client as an error).
Reject(String),
}
```
Currently only `on_message_enqueue` can reject operations. Other hooks are
observational (fire-and-forget).
---
## Event types
### `MessageEvent`
Fired on `enqueue` and `batch_enqueue` RPC calls.
| Field | Type | Description |
|--------------------|-------------------|-------------|
| `sender_identity` | `Option<Vec<u8>>` | Sender's 32-byte identity key (None in sealed sender mode). |
| `recipient_key` | `Vec<u8>` | Recipient's 32-byte identity key. |
| `channel_id` | `Vec<u8>` | 16-byte channel ID. |
| `payload_len` | `usize` | Length of the encrypted payload in bytes. |
| `seq` | `u64` | Server-assigned sequence number. |
### `AuthEvent`
Fired after OPAQUE login completes (success or failure).
| Field | Type | Description |
|------------------|----------|-------------|
| `username` | `String` | The username that attempted to authenticate. |
| `success` | `bool` | Whether authentication succeeded. |
| `failure_reason` | `String` | Failure reason (empty on success). |
### `ChannelEvent`
Fired after a `createChannel` RPC call.
| Field | Type | Description |
|-----------------|------------|-------------|
| `channel_id` | `Vec<u8>` | 16-byte channel ID. |
| `initiator_key` | `Vec<u8>` | Identity key of the channel initiator. |
| `peer_key` | `Vec<u8>` | Identity key of the peer. |
| `was_new` | `bool` | True if this is a newly created channel. |
### `FetchEvent`
Fired after a `fetch` or `fetchWait` RPC call.
| Field | Type | Description |
|-----------------|------------|-------------|
| `recipient_key` | `Vec<u8>` | Identity key of the fetcher. |
| `channel_id` | `Vec<u8>` | Channel ID being fetched from. |
| `message_count` | `usize` | Number of messages returned. |
---
## Built-in implementations
### `NoopHooks`
Does nothing. This is the default when no hooks are configured.
```rust,ignore
pub struct NoopHooks;
impl ServerHooks for NoopHooks {}
```
### `TracingHooks`
Logs all events via the `tracing` crate at info/debug level.
```rust,ignore
pub struct TracingHooks;
impl ServerHooks for TracingHooks {
fn on_message_enqueue(&self, event: &MessageEvent) -> HookAction {
tracing::info!(
recipient_prefix = %hex_prefix(&event.recipient_key),
payload_len = event.payload_len,
seq = event.seq,
"hook: message enqueued"
);
HookAction::Continue
}
fn on_auth(&self, event: &AuthEvent) {
if event.success {
tracing::info!(username = %event.username, "hook: login success");
} else {
tracing::warn!(
username = %event.username,
reason = %event.failure_reason,
"hook: login failure"
);
}
}
// ... other methods log similarly
}
```
---
## Writing a custom hook
### Example: payload size limiter
```rust,ignore
use quicproquo_server::hooks::{ServerHooks, HookAction, MessageEvent};
struct PayloadLimiter {
max_bytes: usize,
}
impl ServerHooks for PayloadLimiter {
fn on_message_enqueue(&self, event: &MessageEvent) -> HookAction {
if event.payload_len > self.max_bytes {
return HookAction::Reject(format!(
"payload too large: {} > {} bytes",
event.payload_len, self.max_bytes
));
}
HookAction::Continue
}
}
```
### Example: login auditor
```rust,ignore
use quicproquo_server::hooks::{ServerHooks, AuthEvent};
struct LoginAuditor;
impl ServerHooks for LoginAuditor {
fn on_auth(&self, event: &AuthEvent) {
if !event.success {
eprintln!(
"AUDIT: failed login for '{}': {}",
event.username, event.failure_reason
);
}
}
}
```
### Example: composing multiple hooks
```rust,ignore
use quicproquo_server::hooks::*;
struct CompositeHooks {
hooks: Vec<Box<dyn ServerHooks>>,
}
impl ServerHooks for CompositeHooks {
fn on_message_enqueue(&self, event: &MessageEvent) -> HookAction {
for hook in &self.hooks {
if let HookAction::Reject(reason) = hook.on_message_enqueue(event) {
return HookAction::Reject(reason);
}
}
HookAction::Continue
}
fn on_auth(&self, event: &AuthEvent) {
for hook in &self.hooks {
hook.on_auth(event);
}
}
// ... delegate other methods similarly
}
```
---
## Important considerations
- **E2E encryption**: Message payloads are encrypted end-to-end. Hooks cannot
inspect plaintext content — they see only metadata (sender, recipient,
payload size, sequence number).
- **Performance**: Hooks run synchronously in the RPC handler. A slow hook
blocks the RPC response. Use `tokio::spawn` for async work.
- **Thread safety**: `ServerHooks` requires `Send + Sync`. Use `Arc<Mutex<_>>`
or lock-free structures for shared mutable state.
- **Reject semantics**: Only `on_message_enqueue` supports rejection. Other
hooks are informational — the operation proceeds regardless of what the hook
does.
---
## Further reading
- [Delivery Service Internals](delivery-service.md) -- how messages flow through the server
- [Authentication Service Internals](authentication-service.md) -- OPAQUE auth flow
- [Bot SDK](../getting-started/bot-sdk.md) -- build bots that interact with the server