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:
19
crates/quicproquo-bot/Cargo.toml
Normal file
19
crates/quicproquo-bot/Cargo.toml
Normal file
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "quicproquo-bot"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Bot SDK for quicproquo — build automated agents on E2E encrypted messaging."
|
||||
license = "MIT"
|
||||
|
||||
[dependencies]
|
||||
quicproquo-core = { path = "../quicproquo-core" }
|
||||
quicproquo-proto = { path = "../quicproquo-proto" }
|
||||
quicproquo-client = { path = "../quicproquo-client" }
|
||||
|
||||
openmls_rust_crypto = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
353
crates/quicproquo-bot/src/lib.rs
Normal file
353
crates/quicproquo-bot/src/lib.rs
Normal file
@@ -0,0 +1,353 @@
|
||||
//! # quicproquo-bot — Bot SDK for E2E encrypted messaging
|
||||
//!
|
||||
//! Build automated agents that run on the quicproquo network with full MLS
|
||||
//! end-to-end encryption. The bot SDK wraps the client library into a simple
|
||||
//! polling-based API: connect, authenticate, send, receive.
|
||||
//!
|
||||
//! ## 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?;
|
||||
//! }
|
||||
//! }
|
||||
//! }
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! ## Pipe mode (stdin/stdout JSON lines)
|
||||
//!
|
||||
//! The bot SDK also supports non-interactive pipe mode for shell integration:
|
||||
//!
|
||||
//! ```bash
|
||||
//! # Send via pipe
|
||||
//! echo '{"to":"alice","text":"hello"}' | qpq pipe --state bot.bin
|
||||
//!
|
||||
//! # Receive via pipe (JSON lines to stdout)
|
||||
//! qpq pipe --recv --state bot.bin
|
||||
//! ```
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Context;
|
||||
use tokio::task::LocalSet;
|
||||
|
||||
use quicproquo_client::{connect_node, init_auth, opaque_login, resolve_user, ClientAuth};
|
||||
use quicproquo_core::IdentityKeypair;
|
||||
|
||||
/// Configuration for connecting a bot to a quicproquo server.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct BotConfig {
|
||||
/// Server address (host:port).
|
||||
pub server: String,
|
||||
/// Path to the server's CA certificate (DER format).
|
||||
pub ca_cert: PathBuf,
|
||||
/// TLS server name (defaults to "localhost").
|
||||
pub server_name: String,
|
||||
/// Bot's username for OPAQUE authentication.
|
||||
pub username: String,
|
||||
/// Bot's password for OPAQUE authentication.
|
||||
pub password: String,
|
||||
/// Path to the bot's encrypted state file.
|
||||
pub state_path: PathBuf,
|
||||
/// Password for the encrypted state file (None = unencrypted).
|
||||
pub state_password: Option<String>,
|
||||
/// Device ID reported to the server.
|
||||
pub device_id: Option<String>,
|
||||
}
|
||||
|
||||
impl BotConfig {
|
||||
/// Create a new bot configuration with required fields.
|
||||
pub fn new(server: &str, username: &str, password: &str) -> Self {
|
||||
Self {
|
||||
server: server.to_string(),
|
||||
ca_cert: PathBuf::from("server-cert.der"),
|
||||
server_name: "localhost".to_string(),
|
||||
username: username.to_string(),
|
||||
password: password.to_string(),
|
||||
state_path: PathBuf::from("bot-state.bin"),
|
||||
state_password: None,
|
||||
device_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the CA certificate path.
|
||||
pub fn ca_cert(mut self, path: &str) -> Self {
|
||||
self.ca_cert = PathBuf::from(path);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the TLS server name for certificate validation.
|
||||
pub fn server_name(mut self, name: &str) -> Self {
|
||||
self.server_name = name.to_string();
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the state file path.
|
||||
pub fn state_path(mut self, path: &str) -> Self {
|
||||
self.state_path = PathBuf::from(path);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the state file encryption password.
|
||||
pub fn state_password(mut self, pwd: &str) -> Self {
|
||||
self.state_password = Some(pwd.to_string());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the device ID.
|
||||
pub fn device_id(mut self, id: &str) -> Self {
|
||||
self.device_id = Some(id.to_string());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// A received message from the quicproquo network.
|
||||
#[derive(Clone, Debug, serde::Serialize)]
|
||||
pub struct Message {
|
||||
/// The sender's username (or "unknown" if resolution failed).
|
||||
pub sender: String,
|
||||
/// The decrypted plaintext message content.
|
||||
pub text: String,
|
||||
/// Server-assigned sequence number.
|
||||
pub seq: u64,
|
||||
}
|
||||
|
||||
/// A bot connected to a quicproquo server.
|
||||
///
|
||||
/// The bot maintains its identity and MLS group state. Each call to
|
||||
/// `send_dm` or `receive` opens a fresh QUIC connection (stateless
|
||||
/// reconnect pattern — same as the CLI client).
|
||||
pub struct Bot {
|
||||
config: BotConfig,
|
||||
identity: Arc<IdentityKeypair>,
|
||||
}
|
||||
|
||||
impl Bot {
|
||||
/// Connect to a quicproquo server and authenticate.
|
||||
///
|
||||
/// Loads or creates an identity from the state file, connects via QUIC/TLS,
|
||||
/// and performs OPAQUE password authentication.
|
||||
pub async fn connect(config: BotConfig) -> anyhow::Result<Self> {
|
||||
let state = quicproquo_client::client::state::load_or_init_state(
|
||||
&config.state_path,
|
||||
config.state_password.as_deref(),
|
||||
)
|
||||
.context("load or init bot state")?;
|
||||
|
||||
let identity = Arc::new(IdentityKeypair::from_seed(state.identity_seed));
|
||||
|
||||
// Authenticate on the first connection.
|
||||
let local = LocalSet::new();
|
||||
let cfg = config.clone();
|
||||
let id = Arc::clone(&identity);
|
||||
|
||||
local
|
||||
.run_until(async {
|
||||
let client =
|
||||
connect_node(&cfg.server, &cfg.ca_cert, &cfg.server_name).await?;
|
||||
|
||||
let pk = id.public_key_bytes();
|
||||
let token = opaque_login(
|
||||
&client,
|
||||
&cfg.username,
|
||||
&cfg.password,
|
||||
&pk,
|
||||
)
|
||||
.await
|
||||
.context("OPAQUE login")?;
|
||||
|
||||
init_auth(ClientAuth::from_raw(token, cfg.device_id.clone()));
|
||||
|
||||
tracing::info!(username = %cfg.username, server = %cfg.server, "bot authenticated");
|
||||
Ok::<(), anyhow::Error>(())
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(Self { config, identity })
|
||||
}
|
||||
|
||||
/// Send a plaintext message to a peer by username.
|
||||
///
|
||||
/// Resolves the username to an identity key, then encrypts via MLS
|
||||
/// and delivers through the server.
|
||||
pub async fn send_dm(&self, peer_username: &str, text: &str) -> anyhow::Result<()> {
|
||||
// Resolve username → identity key hex so we send to the specific peer.
|
||||
let peer_key = self
|
||||
.resolve_user(peer_username)
|
||||
.await
|
||||
.context("resolve peer username")?;
|
||||
let peer_key_hex = hex::encode(&peer_key);
|
||||
|
||||
quicproquo_client::cmd_send(
|
||||
&self.config.state_path,
|
||||
&self.config.server,
|
||||
&self.config.ca_cert,
|
||||
&self.config.server_name,
|
||||
Some(&peer_key_hex),
|
||||
false,
|
||||
text,
|
||||
self.config.state_password.as_deref(),
|
||||
)
|
||||
.await
|
||||
.context("send message")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Receive pending messages, waiting up to `timeout_ms` milliseconds.
|
||||
///
|
||||
/// Returns decrypted application messages. MLS control messages (commits,
|
||||
/// welcomes) are processed internally but not returned.
|
||||
pub async fn receive(&self, timeout_ms: u64) -> anyhow::Result<Vec<Message>> {
|
||||
let plaintexts = quicproquo_client::receive_pending_plaintexts(
|
||||
&self.config.state_path,
|
||||
&self.config.server,
|
||||
&self.config.ca_cert,
|
||||
&self.config.server_name,
|
||||
timeout_ms,
|
||||
self.config.state_password.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let messages: Vec<Message> = plaintexts
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, plaintext)| Message {
|
||||
sender: "peer".to_string(), // TODO: resolve from MLS group roster
|
||||
text: String::from_utf8_lossy(&plaintext).to_string(),
|
||||
seq: i as u64,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(messages)
|
||||
}
|
||||
|
||||
/// Receive raw plaintext bytes (for binary protocols or non-UTF-8 content).
|
||||
pub async fn receive_raw(&self, timeout_ms: u64) -> anyhow::Result<Vec<Vec<u8>>> {
|
||||
quicproquo_client::receive_pending_plaintexts(
|
||||
&self.config.state_path,
|
||||
&self.config.server,
|
||||
&self.config.ca_cert,
|
||||
&self.config.server_name,
|
||||
timeout_ms,
|
||||
self.config.state_password.as_deref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Resolve a username to a 32-byte identity key.
|
||||
pub async fn resolve_user(&self, username: &str) -> anyhow::Result<Vec<u8>> {
|
||||
let local = LocalSet::new();
|
||||
let cfg = self.config.clone();
|
||||
let username = username.to_string();
|
||||
|
||||
local
|
||||
.run_until(async {
|
||||
let client = connect_node(&cfg.server, &cfg.ca_cert, &cfg.server_name).await?;
|
||||
let key = resolve_user(&client, &username)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow::anyhow!("user not found: {username}"))?;
|
||||
Ok(key)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Get the bot's own username.
|
||||
pub fn username(&self) -> &str {
|
||||
&self.config.username
|
||||
}
|
||||
|
||||
/// Get the bot's identity public key (32 bytes, Ed25519).
|
||||
pub fn identity_key(&self) -> [u8; 32] {
|
||||
self.identity.public_key_bytes()
|
||||
}
|
||||
|
||||
/// Get the bot's identity key as a hex string.
|
||||
pub fn identity_key_hex(&self) -> String {
|
||||
hex::encode(self.identity.public_key_bytes())
|
||||
}
|
||||
}
|
||||
|
||||
/// Read JSON commands from stdin and process them.
|
||||
///
|
||||
/// Each line should be a JSON object with:
|
||||
/// - `{"action": "send", "to": "username", "text": "message"}`
|
||||
/// - `{"action": "recv", "timeout_ms": 5000}`
|
||||
/// - `{"action": "resolve", "username": "alice"}`
|
||||
///
|
||||
/// Results are written to stdout as JSON lines.
|
||||
pub async fn run_pipe_mode(bot: &Bot) -> anyhow::Result<()> {
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
|
||||
let stdin = BufReader::new(tokio::io::stdin());
|
||||
let mut lines = stdin.lines();
|
||||
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
let line = line.trim().to_string();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let cmd: serde_json::Value = match serde_json::from_str(&line) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
let err = serde_json::json!({"error": format!("invalid JSON: {e}")});
|
||||
println!("{err}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let action = cmd["action"].as_str().unwrap_or("");
|
||||
let result = match action {
|
||||
"send" => {
|
||||
let to = cmd["to"].as_str().unwrap_or("");
|
||||
let text = cmd["text"].as_str().unwrap_or("");
|
||||
match bot.send_dm(to, text).await {
|
||||
Ok(()) => serde_json::json!({"status": "ok", "action": "send"}),
|
||||
Err(e) => serde_json::json!({"error": format!("{e:#}")}),
|
||||
}
|
||||
}
|
||||
"recv" => {
|
||||
let timeout = cmd["timeout_ms"].as_u64().unwrap_or(5000);
|
||||
match bot.receive(timeout).await {
|
||||
Ok(msgs) => serde_json::json!({"status": "ok", "messages": msgs}),
|
||||
Err(e) => serde_json::json!({"error": format!("{e:#}")}),
|
||||
}
|
||||
}
|
||||
"resolve" => {
|
||||
let username = cmd["username"].as_str().unwrap_or("");
|
||||
match bot.resolve_user(username).await {
|
||||
Ok(key) => serde_json::json!({
|
||||
"status": "ok",
|
||||
"identity_key": hex::encode(&key),
|
||||
}),
|
||||
Err(e) => serde_json::json!({"error": format!("{e:#}")}),
|
||||
}
|
||||
}
|
||||
_ => serde_json::json!({"error": format!("unknown action: {action}")}),
|
||||
};
|
||||
|
||||
println!("{result}");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user