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

@@ -11,7 +11,7 @@ use std::time::Duration;
use anyhow::Context;
use quicproquo_core::{
AppMessage, DiskKeyStore, GroupMember, IdentityKeypair, ReceivedMessage,
hybrid_encrypt, parse as parse_app_msg, serialize_chat,
compute_safety_number, hybrid_encrypt, parse as parse_app_msg, serialize_chat,
};
use quicproquo_proto::node_capnp::node_service;
use tokio::sync::mpsc;
@@ -57,6 +57,8 @@ enum SlashCommand {
/// Mesh subcommands: /mesh peers, /mesh server <addr>
MeshPeers,
MeshServer { addr: String },
/// Display safety number for out-of-band key verification with a contact.
Verify { username: String },
}
fn parse_input(line: &str) -> Input {
@@ -135,6 +137,13 @@ fn parse_input(line: &str) -> Input {
Input::Empty
}
},
"/verify" => match arg {
Some(username) => Input::Slash(SlashCommand::Verify { username }),
None => {
display::print_error("usage: /verify <username>");
Input::Empty
}
},
_ => {
display::print_error(&format!("unknown command: {cmd}. Try /help"));
Input::Empty
@@ -601,6 +610,7 @@ async fn handle_slash(
));
Ok(())
}
SlashCommand::Verify { username } => cmd_verify(session, client, &username).await,
};
if let Err(e) = result {
display::print_error(&format!("{e:#}"));
@@ -622,6 +632,7 @@ fn print_help() {
display::print_status(" /whoami - Show your identity");
display::print_status(" /mesh peers - Discover nearby qpq nodes via mDNS");
display::print_status(" /mesh server <host:port> - Show how to reconnect to a mesh node");
display::print_status(" /verify <username> - Show safety number for key verification");
display::print_status(" /quit - Exit");
}
@@ -1200,6 +1211,43 @@ fn cmd_history(session: &SessionState, count: usize) -> anyhow::Result<()> {
Ok(())
}
async fn cmd_verify(
session: &SessionState,
client: &node_service::Client,
username: &str,
) -> anyhow::Result<()> {
// Resolve contact's identity key from the server.
display::print_status(&format!("resolving {username}..."));
let peer_key_vec = resolve_user(client, username)
.await?
.with_context(|| format!("user '{username}' not found"))?;
anyhow::ensure!(
peer_key_vec.len() == 32,
"server returned an identity key with unexpected length ({}); expected 32 bytes",
peer_key_vec.len()
);
let peer_key: [u8; 32] = peer_key_vec
.as_slice()
.try_into()
.expect("length checked above");
let my_key: [u8; 32] = session.identity.public_key_bytes();
let safety_number = compute_safety_number(&my_key, &peer_key);
display::print_status(&format!("Safety number with @{username}:"));
display::print_status("");
display::print_status(&format!(" {safety_number}"));
display::print_status("");
display::print_status("Compare this number with your contact via a separate channel");
display::print_status("(voice call, in person, or any out-of-band means).");
display::print_status("If the numbers match, the connection has not been tampered with.");
Ok(())
}
// ── Sending ──────────────────────────────────────────────────────────────────
async fn handle_send(