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

@@ -1288,3 +1288,111 @@ pub async fn cmd_chat(
println!();
Ok(())
}
// ── Transcript export ─────────────────────────────────────────────────────────
/// Export the message history for a conversation to an encrypted, tamper-evident
/// transcript file.
///
/// `conv_db` is the path to the conversation SQLite database (`.convdb` file).
/// `conv_id_hex` is the 32-hex-character conversation ID to export.
/// `output` is the path for the `.qpqt` transcript file to write.
/// `transcript_password` is used to derive the encryption key (Argon2id).
/// `db_password` is the optional SQLCipher password for the conversation database.
pub fn cmd_export(
conv_db: &Path,
conv_id_hex: &str,
output: &Path,
transcript_password: &str,
db_password: Option<&str>,
) -> anyhow::Result<()> {
use quicproquo_core::{TranscriptRecord, TranscriptWriter};
use super::conversation::{ConversationId, ConversationStore};
// Decode conversation ID from hex.
let id_bytes = hex::decode(conv_id_hex)
.map_err(|e| anyhow::anyhow!("conv-id must be 32 hex characters (16 bytes): {e}"))?;
let conv_id = ConversationId::from_slice(&id_bytes)
.ok_or_else(|| anyhow::anyhow!("conv-id must be exactly 16 bytes (32 hex chars), got {} bytes", id_bytes.len()))?;
// Open conversation database.
let store = ConversationStore::open(conv_db, db_password)
.context("open conversation database")?;
// Load conversation metadata (to display name in output).
let conv = store
.load_conversation(&conv_id)?
.with_context(|| format!("conversation '{conv_id_hex}' not found in database"))?;
// Load all messages (oldest first).
let messages = store.load_all_messages(&conv_id)?;
if messages.is_empty() {
println!("No messages in conversation '{}'.", conv.display_name);
return Ok(());
}
// Create output file.
if let Some(parent) = output.parent() {
std::fs::create_dir_all(parent).ok();
}
let mut file = std::fs::File::create(output)
.with_context(|| format!("create transcript file '{}'", output.display()))?;
// Write transcript header + records.
let mut writer = TranscriptWriter::new(transcript_password, &mut file)
.context("initialise transcript writer")?;
let mut written = 0u64;
for (seq, msg) in messages.iter().enumerate() {
writer
.write_record(
&TranscriptRecord {
seq: seq as u64,
sender_identity: &msg.sender_key,
timestamp_ms: msg.timestamp_ms,
plaintext: &msg.body,
},
&mut file,
)
.context("write transcript record")?;
written += 1;
}
println!(
"Exported {} message(s) from '{}' to '{}'.",
written,
conv.display_name,
output.display()
);
println!("Decrypt with: qpq export verify --input <file> --password <password>");
Ok(())
}
/// Verify the hash-chain integrity of a transcript file without decrypting content.
///
/// Prints a summary. Does not require the encryption password (structural check only).
pub fn cmd_export_verify(input: &Path) -> anyhow::Result<()> {
use quicproquo_core::{verify_transcript_chain, ChainVerdict};
let data = std::fs::read(input)
.with_context(|| format!("read transcript file '{}'", input.display()))?;
match verify_transcript_chain(&data)? {
ChainVerdict::Ok { records } => {
println!(
"OK: transcript '{}' is structurally valid. {} record(s) found, hash chain intact.",
input.display(),
records
);
}
ChainVerdict::Broken => {
anyhow::bail!(
"FAIL: hash chain is broken in '{}' — file may have been tampered with.",
input.display()
);
}
}
Ok(())
}