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:
@@ -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(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user