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

@@ -12,6 +12,7 @@ path = "src/main.rs"
[dependencies]
quicproquo-core = { path = "../quicproquo-core" }
quicproquo-proto = { path = "../quicproquo-proto" }
quicproquo-kt = { path = "../quicproquo-kt" }
openmls_rust_crypto = { workspace = true }
# Serialisation + RPC
@@ -38,6 +39,7 @@ thiserror = { workspace = true }
sha2 = { workspace = true }
argon2 = { workspace = true }
chacha20poly1305 = { workspace = true }
ciborium = { workspace = true }
zeroize = { workspace = true }
quinn = { workspace = true }
quinn-proto = { workspace = true }
@@ -65,10 +67,16 @@ mdns-sd = { version = "0.12", optional = true }
# Optional P2P transport for direct node-to-node messaging.
quicproquo-p2p = { path = "../quicproquo-p2p", optional = true }
# Optional TUI dependencies (Ratatui full-screen interface).
ratatui = { version = "0.29", optional = true, default-features = false, features = ["crossterm"] }
crossterm = { version = "0.28", optional = true }
[features]
# Enable mesh-mode features: mDNS local peer discovery + P2P transport.
# Build: cargo build -p quicproquo-client --features mesh
mesh = ["dep:mdns-sd", "dep:quicproquo-p2p"]
# Enable full-screen Ratatui TUI: cargo build -p quicproquo-client --features tui
tui = ["dep:ratatui", "dep:crossterm"]
[dev-dependencies]
dashmap = { workspace = true }

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(())
}

View File

@@ -587,6 +587,55 @@ impl ConversationStore {
Ok(msgs)
}
/// Load all messages for a conversation, oldest first (no limit).
pub fn load_all_messages(&self, conv_id: &ConversationId) -> anyhow::Result<Vec<StoredMessage>> {
let mut stmt = self.conn.prepare(
"SELECT message_id, sender_key, sender_name, body, msg_type,
ref_msg_id, timestamp_ms, is_outgoing
FROM messages
WHERE conversation_id = ?1
ORDER BY timestamp_ms ASC, id ASC",
)?;
let rows = stmt.query_map(params![conv_id.0.as_slice()], |row| {
let message_id: Option<Vec<u8>> = row.get(0)?;
let sender_key: Vec<u8> = row.get(1)?;
let sender_name: Option<String> = row.get(2)?;
let body: String = row.get(3)?;
let msg_type: String = row.get(4)?;
let ref_msg_id: Option<Vec<u8>> = row.get(5)?;
let timestamp_ms: u64 = row.get(6)?;
let is_outgoing: i32 = row.get(7)?;
fn to_16(v: &[u8]) -> Option<[u8; 16]> {
if v.len() == 16 {
let mut buf = [0u8; 16];
buf.copy_from_slice(v);
Some(buf)
} else {
None
}
}
Ok(StoredMessage {
conversation_id: conv_id.clone(),
message_id: message_id.as_deref().and_then(to_16),
sender_key,
sender_name,
body,
msg_type,
ref_msg_id: ref_msg_id.as_deref().and_then(to_16),
timestamp_ms,
is_outgoing: is_outgoing != 0,
})
})?;
let mut msgs = Vec::new();
for row in rows {
msgs.push(row?);
}
Ok(msgs)
}
/// Save a message, deduplicating by message_id within the same conversation.
/// Returns `true` if the message was saved (new), `false` if it was a duplicate.
pub fn save_message_dedup(&self, msg: &StoredMessage) -> anyhow::Result<bool> {

View File

@@ -9,6 +9,8 @@ pub mod rpc;
pub mod session;
pub mod state;
pub mod token_cache;
#[cfg(feature = "tui")]
pub mod tui;
pub use commands::*;
pub use rpc::{connect_node, enqueue, fetch_all, fetch_hybrid_key, fetch_key_package, fetch_wait, upload_hybrid_key, upload_key_package};

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(

View File

@@ -576,6 +576,13 @@ pub async fn batch_enqueue(
}
/// Resolve a username to its Ed25519 identity key (32 bytes).
///
/// When the server returns a non-empty `inclusionProof`, the client verifies it
/// against the identity key using the Key Transparency Merkle proof. Proof
/// verification failure is treated as a hard error (the server is misbehaving).
/// If the server sends no proof (empty field), the key is returned as-is —
/// callers can decide whether to require proofs for security-critical flows.
///
/// Returns `None` if the username is not registered.
pub async fn resolve_user(
client: &node_service::Client,
@@ -595,18 +602,31 @@ pub async fn resolve_user(
.await
.context("resolve_user RPC failed")?;
let key = resp
.get()
.context("resolve_user: bad response")?
let reader = resp.get().context("resolve_user: bad response")?;
let key = reader
.get_identity_key()
.context("resolve_user: missing field")?
.context("resolve_user: missing identity_key field")?
.to_vec();
if key.is_empty() {
Ok(None)
} else {
Ok(Some(key))
return Ok(None);
}
// Verify the KT inclusion proof when the server sends one.
let proof_bytes = reader
.get_inclusion_proof()
.context("resolve_user: missing inclusion_proof field")?
.to_vec();
if !proof_bytes.is_empty() {
let proof = quicproquo_kt::InclusionProof::from_bytes(&proof_bytes)
.context("resolve_user: inclusion proof deserialise failed")?;
quicproquo_kt::verify_inclusion(&proof, username, &key)
.context("resolve_user: KT inclusion proof verification FAILED — possible key mislabelling")?;
}
Ok(Some(key))
}
/// Reverse lookup: resolve an identity key to the registered username.

View File

@@ -0,0 +1,807 @@
//! Full-screen Ratatui TUI for quicproquo.
//!
//! Layout:
//! ┌──────────────┬──────────────────────────────────────────┐
//! │ Channels │ Messages │
//! │ (20%) │ (80%) │
//! │ │ │
//! │ ├──────────────────────────────────────────┤
//! │ │ Input bar │
//! └──────────────┴──────────────────────────────────────────┘
//!
//! Keyboard:
//! Enter — send message
//! Up / Down — scroll message history
//! Tab — next channel
//! Shift+Tab — prev channel
//! Ctrl+C / q — quit
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
use anyhow::Context;
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
backend::CrosstermBackend,
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap},
Frame, Terminal,
};
use tokio::sync::mpsc;
use tokio::time::interval;
use crate::{ClientAuth, init_auth};
use super::commands::{opaque_login, opaque_register};
use super::conversation::{now_ms, ConversationId, StoredMessage};
use super::rpc::{
connect_node, enqueue, fetch_hybrid_key, fetch_wait, try_hybrid_decrypt, upload_hybrid_key,
upload_key_package,
};
use super::session::SessionState;
use super::state::load_or_init_state;
use super::token_cache::{load_cached_session, save_cached_session};
use quicproquo_core::{
AppMessage, DiskKeyStore, GroupMember, IdentityKeypair, ReceivedMessage,
hybrid_encrypt, parse as parse_app_msg, serialize_chat,
};
use quicproquo_proto::node_capnp::node_service;
// ── App events ───────────────────────────────────────────────────────────────
/// Events sent from background tasks into the main TUI loop.
enum TuiEvent {
/// A key event from the terminal.
Key(event::KeyEvent),
/// New messages received from the server (conv_id, sender_short, body).
NewMessages(Vec<(ConversationId, String, String)>),
/// Tick — redraw periodically even if nothing happened.
Tick,
}
// ── Display message ───────────────────────────────────────────────────────────
#[derive(Clone)]
struct DisplayMessage {
sender: String,
body: String,
timestamp_ms: u64,
is_outgoing: bool,
}
// ── App state ─────────────────────────────────────────────────────────────────
struct App {
/// Channel (conversation) names shown in the sidebar.
channel_names: Vec<String>,
/// Conversation IDs, parallel to `channel_names`.
channel_ids: Vec<ConversationId>,
/// Index of the selected channel in the sidebar.
selected_channel: usize,
/// Messages for the currently active channel.
messages: Vec<DisplayMessage>,
/// Current input buffer.
input: String,
/// Scroll offset (0 = bottom).
scroll_offset: usize,
/// Whether the user has requested quit.
should_quit: bool,
/// Short identity string for display.
identity_short: String,
}
impl App {
fn new(session: &SessionState) -> anyhow::Result<Self> {
let convs = session.conv_store.list_conversations()?;
let channel_names: Vec<String> = convs.iter().map(|c| c.display_name.clone()).collect();
let channel_ids: Vec<ConversationId> = convs.iter().map(|c| c.id.clone()).collect();
Ok(Self {
channel_names,
channel_ids,
selected_channel: 0,
messages: Vec::new(),
input: String::new(),
scroll_offset: 0,
should_quit: false,
identity_short: session.identity_short(),
})
}
fn active_conv_id(&self) -> Option<&ConversationId> {
self.channel_ids.get(self.selected_channel)
}
/// Reload messages for the currently selected channel from the session store.
fn reload_messages(&mut self, session: &SessionState) -> anyhow::Result<()> {
let conv_id = match self.active_conv_id() {
Some(id) => id.clone(),
None => {
self.messages.clear();
return Ok(());
}
};
let stored = session.conv_store.load_recent_messages(&conv_id, 200)?;
self.messages = stored
.into_iter()
.map(|m| {
let sender = if m.is_outgoing {
format!("me({})", &self.identity_short)
} else if let Some(name) = &m.sender_name {
name.clone()
} else {
// Shorten sender key to 8 hex chars.
let hex_short = hex::encode(&m.sender_key[..m.sender_key.len().min(4)]);
format!("{hex_short}")
};
DisplayMessage {
sender,
body: m.body,
timestamp_ms: m.timestamp_ms,
is_outgoing: m.is_outgoing,
}
})
.collect();
// Reset scroll to bottom on channel switch.
self.scroll_offset = 0;
Ok(())
}
fn select_next_channel(&mut self, session: &SessionState) {
if self.channel_names.is_empty() {
return;
}
self.selected_channel = (self.selected_channel + 1) % self.channel_names.len();
let _ = self.reload_messages(session);
}
fn select_prev_channel(&mut self, session: &SessionState) {
if self.channel_names.is_empty() {
return;
}
if self.selected_channel == 0 {
self.selected_channel = self.channel_names.len() - 1;
} else {
self.selected_channel -= 1;
}
let _ = self.reload_messages(session);
}
fn scroll_up(&mut self) {
self.scroll_offset = self.scroll_offset.saturating_add(1);
}
fn scroll_down(&mut self) {
self.scroll_offset = self.scroll_offset.saturating_sub(1);
}
/// Append newly received messages to the in-memory list (no DB reload needed
/// since we already have them from the poll task, but we also save them via
/// the session so they appear on reload).
fn append_messages(&mut self, msgs: Vec<(ConversationId, String, String)>) {
let active = self.active_conv_id().cloned();
for (conv_id, sender, body) in msgs {
if active.as_ref() == Some(&conv_id) {
self.messages.push(DisplayMessage {
sender,
body,
timestamp_ms: now_ms(),
is_outgoing: false,
});
// Snap to bottom if user wasn't scrolled.
if self.scroll_offset == 0 {
// Already at bottom — nothing to do.
}
}
}
}
}
// ── Drawing ───────────────────────────────────────────────────────────────────
fn ui(frame: &mut Frame, app: &App) {
let size = frame.area();
// Top-level split: sidebar | main area.
let h_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(20), Constraint::Percentage(80)])
.split(size);
// Main area split: messages | input bar.
let v_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(3), Constraint::Length(3)])
.split(h_chunks[1]);
draw_sidebar(frame, app, h_chunks[0]);
draw_messages(frame, app, v_chunks[0]);
draw_input(frame, app, v_chunks[1]);
}
fn draw_sidebar(frame: &mut Frame, app: &App, area: Rect) {
let items: Vec<ListItem> = app
.channel_names
.iter()
.enumerate()
.map(|(i, name)| {
let style = if i == app.selected_channel {
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD | Modifier::REVERSED)
} else {
Style::default().fg(Color::Cyan)
};
ListItem::new(Line::from(Span::styled(name.clone(), style)))
})
.collect();
let block = Block::default()
.title(" Channels ")
.borders(Borders::ALL)
.style(Style::default().fg(Color::DarkGray));
let mut list_state = ListState::default();
if !app.channel_names.is_empty() {
list_state.select(Some(app.selected_channel));
}
frame.render_stateful_widget(
List::new(items).block(block),
area,
&mut list_state,
);
}
fn draw_messages(frame: &mut Frame, app: &App, area: Rect) {
let channel_title = app
.channel_names
.get(app.selected_channel)
.map(|n| format!(" {n} "))
.unwrap_or_else(|| " Messages ".to_string());
let block = Block::default()
.title(channel_title)
.borders(Borders::ALL)
.style(Style::default().fg(Color::DarkGray));
let inner_height = area.height.saturating_sub(2) as usize;
// Build lines from messages (newest at bottom).
let mut lines: Vec<Line> = app
.messages
.iter()
.map(|m| {
let ts = format_timestamp(m.timestamp_ms);
let ts_span = Span::styled(ts, Style::default().fg(Color::DarkGray));
let sender_style = if m.is_outgoing {
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)
};
let sender_span = Span::styled(format!(" {} ", m.sender), sender_style);
let body_span = Span::raw(m.body.clone());
Line::from(vec![ts_span, sender_span, body_span])
})
.collect();
// Apply scroll: scroll_offset=0 means newest at bottom.
let total = lines.len();
let visible_start = if total > inner_height {
let bottom = total - app.scroll_offset.min(total);
bottom.saturating_sub(inner_height)
} else {
0
};
let visible_end = if total > inner_height {
total - app.scroll_offset.min(total)
} else {
total
};
let visible_lines: Vec<Line> = lines
.drain(visible_start..visible_end.min(lines.len()))
.collect();
let paragraph = Paragraph::new(visible_lines)
.block(block)
.wrap(Wrap { trim: false });
frame.render_widget(paragraph, area);
}
fn draw_input(frame: &mut Frame, app: &App, area: Rect) {
let block = Block::default()
.title(" Input (Enter=send, Tab=switch channel, q/Ctrl+C=quit) ")
.borders(Borders::ALL)
.style(Style::default().fg(Color::DarkGray));
let input_text = Paragraph::new(app.input.as_str())
.block(block)
.style(Style::default().fg(Color::White));
frame.render_widget(input_text, area);
// Position cursor at end of input.
let cursor_x = area.x + 1 + app.input.len() as u16;
let cursor_y = area.y + 1;
if cursor_x < area.x + area.width - 1 {
frame.set_cursor_position((cursor_x, cursor_y));
}
}
fn format_timestamp(ms: u64) -> String {
// Simple HH:MM format from epoch ms.
let secs = ms / 1000;
let hours = (secs / 3600) % 24;
let minutes = (secs / 60) % 60;
format!("[{:02}:{:02}] ", hours, minutes)
}
// ── Message polling task ──────────────────────────────────────────────────────
/// Background task that polls the server for new messages and sends them via `tx`.
async fn poll_task(
mut session: SessionState,
client: node_service::Client,
tx: mpsc::Sender<TuiEvent>,
) {
let mut poll_interval = interval(Duration::from_millis(1000));
poll_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
loop {
poll_interval.tick().await;
let identity_bytes = session.identity_bytes();
let payloads = match fetch_wait(&client, &identity_bytes, 0).await {
Ok(p) => p,
Err(_) => continue,
};
if payloads.is_empty() {
continue;
}
let mut new_msgs: Vec<(ConversationId, String, String)> = Vec::new();
let my_key = session.identity_bytes();
let mut sorted = payloads;
sorted.sort_by_key(|(seq, _)| *seq);
for (_seq, payload) in &sorted {
let mls_payload = match try_hybrid_decrypt(session.hybrid_kp.as_ref(), payload) {
Ok(b) => b,
Err(_) => payload.clone(),
};
let conv_ids: Vec<ConversationId> = session.members.keys().cloned().collect();
for conv_id in &conv_ids {
let member = match session.members.get_mut(conv_id) {
Some(m) => m,
None => continue,
};
match member.receive_message(&mls_payload) {
Ok(ReceivedMessage::Application(plaintext)) => {
let (sender_key, app_bytes) = {
let after_unpad = quicproquo_core::padding::unpad(&plaintext)
.unwrap_or_else(|_| plaintext.clone());
if quicproquo_core::sealed_sender::is_sealed(&after_unpad) {
match quicproquo_core::sealed_sender::unseal(&after_unpad) {
Ok((sk, inner)) => (sk.to_vec(), inner),
Err(_) => (my_key.clone(), after_unpad),
}
} else {
(my_key.clone(), after_unpad)
}
};
let (body, msg_id, msg_type, ref_msg_id) =
match parse_app_msg(&app_bytes) {
Ok((_, AppMessage::Chat { message_id, body })) => (
String::from_utf8_lossy(&body).to_string(),
Some(message_id),
"chat",
None,
),
Ok((_, AppMessage::Reply { ref_msg_id, body })) => (
String::from_utf8_lossy(&body).to_string(),
None,
"reply",
Some(ref_msg_id),
),
Ok((_, AppMessage::Reaction { ref_msg_id, emoji })) => (
String::from_utf8_lossy(&emoji).to_string(),
None,
"reaction",
Some(ref_msg_id),
),
_ => (
String::from_utf8_lossy(&app_bytes).to_string(),
None,
"chat",
None,
),
};
let stored = StoredMessage {
conversation_id: conv_id.clone(),
message_id: msg_id,
sender_key: sender_key.clone(),
sender_name: None,
body: body.clone(),
msg_type: msg_type.into(),
ref_msg_id,
timestamp_ms: now_ms(),
is_outgoing: false,
};
if session.conv_store.save_message(&stored).is_ok() {
let sender_short = hex::encode(&sender_key[..sender_key.len().min(4)]);
new_msgs.push((conv_id.clone(), sender_short, body));
}
let _ = session.conv_store.update_activity(conv_id, now_ms());
let _ = session.save_member(conv_id);
break;
}
Ok(ReceivedMessage::StateChanged) | Ok(ReceivedMessage::SelfRemoved) => {
let _ = session.save_member(conv_id);
break;
}
_ => {}
}
}
}
if !new_msgs.is_empty() {
if tx.send(TuiEvent::NewMessages(new_msgs)).await.is_err() {
break;
}
}
}
}
// ── Send message ──────────────────────────────────────────────────────────────
async fn send_message(
session: &mut SessionState,
client: &node_service::Client,
conv_id: &ConversationId,
text: &str,
) -> anyhow::Result<()> {
let my_key = session.identity_bytes();
let identity = Arc::clone(&session.identity);
let member = session
.members
.get_mut(conv_id)
.context("no GroupMember for this conversation")?;
// Wrap in structured AppMessage format.
let app_payload = serialize_chat(text.as_bytes(), None)
.context("serialize app message")?;
// Metadata protection: seal + pad.
let sealed = quicproquo_core::sealed_sender::seal(&identity, &app_payload);
let padded = quicproquo_core::padding::pad(&sealed);
let ct = member.send_message(&padded).context("MLS encrypt")?;
let recipients: Vec<Vec<u8>> = member
.member_identities()
.into_iter()
.filter(|id| id.as_slice() != my_key.as_slice())
.collect();
for recipient_key in &recipients {
let peer_hybrid_pk = fetch_hybrid_key(client, recipient_key).await?;
let payload = if let Some(ref pk) = peer_hybrid_pk {
hybrid_encrypt(pk, &ct, b"", b"").context("hybrid encrypt")?
} else {
ct.clone()
};
enqueue(client, recipient_key, &payload).await?;
}
// Extract message_id from what we just serialized.
let msg_id = parse_app_msg(&app_payload)
.ok()
.and_then(|(_, m)| match m {
AppMessage::Chat { message_id, .. } => Some(message_id),
_ => None,
});
// Save outgoing message.
let stored = StoredMessage {
conversation_id: conv_id.clone(),
message_id: msg_id,
sender_key: my_key,
sender_name: Some("you".into()),
body: text.to_string(),
msg_type: "chat".into(),
ref_msg_id: None,
timestamp_ms: now_ms(),
is_outgoing: true,
};
session.conv_store.save_message(&stored)?;
session.conv_store.update_activity(conv_id, now_ms())?;
session.save_member(conv_id)?;
Ok(())
}
// ── TUI entry point ───────────────────────────────────────────────────────────
/// Entry point for `qpq tui`. Sets up the terminal, runs the event loop, and
/// restores the terminal on exit.
pub async fn run_tui(
state_path: &Path,
server: &str,
ca_cert: &Path,
server_name: &str,
password: Option<&str>,
username: Option<&str>,
opaque_password: Option<&str>,
access_token: &str,
device_id: Option<&str>,
) -> anyhow::Result<()> {
// ── Auth ──────────────────────────────────────────────────────────────────
let resolved_token = resolve_tui_access_token(
state_path,
server,
ca_cert,
server_name,
password,
username,
opaque_password,
access_token,
)
.await?;
let token_bytes = hex::decode(&resolved_token)
.unwrap_or_else(|_| resolved_token.into_bytes());
let auth_ctx = ClientAuth::from_raw(token_bytes, device_id.map(String::from));
init_auth(auth_ctx);
// ── Session + RPC ─────────────────────────────────────────────────────────
let mut session = SessionState::load(state_path, password)?;
let client = connect_node(server, ca_cert, server_name).await?;
// Auto-upload KeyPackage.
let _ = auto_upload_keys_tui(&session, &client).await;
// ── Terminal setup ────────────────────────────────────────────────────────
enable_raw_mode().context("enable raw mode")?;
let mut stdout = std::io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)
.context("enter alternate screen")?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend).context("create terminal")?;
let result = tui_loop(&mut terminal, &mut session, client).await;
// ── Terminal cleanup (always restore, even on error) ───────────────────
disable_raw_mode().ok();
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)
.ok();
terminal.show_cursor().ok();
session.save_all()?;
result
}
async fn tui_loop(
terminal: &mut Terminal<CrosstermBackend<std::io::Stdout>>,
session: &mut SessionState,
client: node_service::Client,
) -> anyhow::Result<()> {
let mut app = App::new(session)?;
app.reload_messages(session)?;
let (event_tx, mut event_rx) = mpsc::channel::<TuiEvent>(256);
// ── Keyboard event task ───────────────────────────────────────────────────
let key_tx = event_tx.clone();
tokio::task::spawn_local(async move {
loop {
// crossterm event polling — 50ms timeout so we can tick.
match event::poll(Duration::from_millis(50)) {
Ok(true) => {
if let Ok(Event::Key(key)) = event::read() {
if key_tx.send(TuiEvent::Key(key)).await.is_err() {
break;
}
}
}
Ok(false) => {
// No event — send a tick so the UI redraws.
if key_tx.send(TuiEvent::Tick).await.is_err() {
break;
}
}
Err(_) => break,
}
}
});
// ── Message poll task ─────────────────────────────────────────────────────
// Clone session state for the poll task (it needs its own SessionState).
let poll_session = SessionState::load(
&session.state_path.clone(),
session.password.as_deref(),
)?;
let poll_tx = event_tx.clone();
tokio::task::spawn_local(poll_task(poll_session, client.clone(), poll_tx));
// ── Main loop ─────────────────────────────────────────────────────────────
loop {
terminal.draw(|f| ui(f, &app)).context("draw")?;
match event_rx.recv().await {
None => break,
Some(TuiEvent::Tick) => {
// Just redraw.
}
Some(TuiEvent::NewMessages(msgs)) => {
app.append_messages(msgs);
}
Some(TuiEvent::Key(key)) => {
match key.code {
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
app.should_quit = true;
}
KeyCode::Char('q') if app.input.is_empty() => {
app.should_quit = true;
}
KeyCode::Enter => {
let text = app.input.trim().to_string();
if !text.is_empty() {
app.input.clear();
if let Some(conv_id) = app.active_conv_id().cloned() {
match send_message(session, &client, &conv_id, &text).await {
Ok(()) => {
// Add to in-memory list immediately.
app.messages.push(DisplayMessage {
sender: format!("me({})", app.identity_short),
body: text,
timestamp_ms: now_ms(),
is_outgoing: true,
});
}
Err(_e) => {
// Silently drop — user will see nothing happened.
}
}
}
}
}
KeyCode::Char(c) => {
app.input.push(c);
}
KeyCode::Backspace => {
app.input.pop();
}
KeyCode::Up => {
app.scroll_up();
}
KeyCode::Down => {
app.scroll_down();
}
KeyCode::Tab => {
if key.modifiers.contains(KeyModifiers::SHIFT) {
app.select_prev_channel(session);
} else {
app.select_next_channel(session);
}
app.reload_messages(session)?;
}
_ => {}
}
}
}
if app.should_quit {
break;
}
}
Ok(())
}
// ── Startup helpers ───────────────────────────────────────────────────────────
async fn auto_upload_keys_tui(
session: &SessionState,
client: &node_service::Client,
) -> anyhow::Result<()> {
let ks_path = session.state_path.with_extension("pending.ks");
let ks = DiskKeyStore::persistent(&ks_path).unwrap_or_else(|_| DiskKeyStore::ephemeral());
let mut member = GroupMember::new_with_state(
Arc::clone(&session.identity),
ks,
None,
false,
);
let kp_bytes = member.generate_key_package().context("generate KeyPackage")?;
let id_key = session.identity.public_key_bytes();
upload_key_package(client, &id_key, &kp_bytes).await?;
if let Some(ref hkp) = session.hybrid_kp {
upload_hybrid_key(client, &id_key, &hkp.public_key()).await?;
}
Ok(())
}
async fn resolve_tui_access_token(
state_path: &Path,
server: &str,
ca_cert: &Path,
server_name: &str,
state_password: Option<&str>,
username: Option<&str>,
opaque_password: Option<&str>,
cli_access_token: &str,
) -> anyhow::Result<String> {
if !cli_access_token.is_empty() {
return Ok(cli_access_token.to_string());
}
if let Some(cached) = load_cached_session(state_path, state_password) {
return Ok(cached.token_hex);
}
let username = match username {
Some(u) => u.to_string(),
None => {
use std::io::Write;
eprint!("Username: ");
std::io::stderr().flush().ok();
let mut input = String::new();
std::io::stdin()
.read_line(&mut input)
.context("failed to read username")?;
let trimmed = input.trim().to_string();
anyhow::ensure!(!trimmed.is_empty(), "username is required");
trimmed
}
};
let opaque_password = match opaque_password {
Some(p) => p.to_string(),
None => rpassword::read_password().context("failed to read password")?,
};
let state = load_or_init_state(state_path, state_password)?;
let identity = IdentityKeypair::from_seed(state.identity_seed);
let identity_key = identity.public_key_bytes().to_vec();
let node_client = connect_node(server, ca_cert, server_name).await?;
match opaque_register(&node_client, &username, &opaque_password, Some(&identity_key)).await {
Ok(()) | Err(_) => {}
}
let token_bytes = opaque_login(&node_client, &username, &opaque_password, &identity_key)
.await
.context("OPAQUE login failed")?;
let token_hex = hex::encode(&token_bytes);
save_cached_session(state_path, &username, &token_hex, state_password)?;
Ok(token_hex)
}

View File

@@ -19,10 +19,10 @@ use std::sync::RwLock;
pub mod client;
pub use client::commands::{
cmd_chat, cmd_check_key, cmd_create_group, cmd_demo_group, cmd_fetch_key, cmd_health,
cmd_health_json, cmd_invite, cmd_join, cmd_login, cmd_ping, cmd_recv, cmd_register,
cmd_register_state, cmd_refresh_keypackage, cmd_register_user, cmd_send, cmd_whoami,
opaque_login, receive_pending_plaintexts, whoami_json,
cmd_chat, cmd_check_key, cmd_create_group, cmd_demo_group, cmd_export, cmd_export_verify,
cmd_fetch_key, cmd_health, cmd_health_json, cmd_invite, cmd_join, cmd_login, cmd_ping,
cmd_recv, cmd_register, cmd_register_state, cmd_refresh_keypackage, cmd_register_user,
cmd_send, cmd_whoami, opaque_login, receive_pending_plaintexts, whoami_json,
};
pub use client::repl::run_repl;

View File

@@ -2,14 +2,17 @@
use std::path::PathBuf;
use anyhow::Context;
use clap::{Parser, Subcommand};
use quicproquo_client::{
cmd_chat, cmd_check_key, cmd_create_group, cmd_demo_group, cmd_fetch_key, cmd_health,
cmd_invite, cmd_join, cmd_login, cmd_ping, cmd_recv, cmd_register, cmd_register_state,
cmd_refresh_keypackage, cmd_register_user, cmd_send, cmd_whoami, init_auth, run_repl,
ClientAuth,
cmd_chat, cmd_check_key, cmd_create_group, cmd_demo_group, cmd_export, cmd_export_verify,
cmd_fetch_key, cmd_health, cmd_invite, cmd_join, cmd_login, cmd_ping, cmd_recv, cmd_register,
cmd_register_state, cmd_refresh_keypackage, cmd_register_user, cmd_send, cmd_whoami,
init_auth, run_repl, ClientAuth,
};
#[cfg(feature = "tui")]
use quicproquo_client::client::tui::run_tui;
// ── CLI ───────────────────────────────────────────────────────────────────────
@@ -310,6 +313,26 @@ enum Command {
no_server: bool,
},
/// Full-screen Ratatui TUI (requires --features tui).
/// Channels sidebar, scrollable message view, and inline input bar.
#[cfg(feature = "tui")]
Tui {
#[arg(
long,
default_value = "qpq-state.bin",
env = "QPQ_STATE"
)]
state: PathBuf,
#[arg(long, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")]
server: String,
/// OPAQUE username for automatic registration/login.
#[arg(long, env = "QPQ_USERNAME")]
username: Option<String>,
/// OPAQUE password (prompted securely if --username is set but --password is not).
#[arg(long, env = "QPQ_PASSWORD")]
password: Option<String>,
},
/// Interactive 1:1 chat: type to send, incoming messages printed as [peer] <msg>. Ctrl+D to exit.
/// In a two-person group, peer is chosen automatically; use --peer-key only with 3+ members.
Chat {
@@ -328,6 +351,39 @@ enum Command {
#[arg(long, default_value_t = 500)]
poll_interval_ms: u64,
},
/// Export a conversation's message history to an encrypted, tamper-evident transcript file.
///
/// The output file uses Argon2id + ChaCha20-Poly1305 encryption with a SHA-256 hash chain
/// linking every record. Use `qpq export verify` to check chain integrity without decrypting.
Export {
/// Path to the conversation database (.convdb file).
#[arg(long, default_value = "qpq-convdb.sqlite", env = "QPQ_CONV_DB")]
conv_db: PathBuf,
/// Conversation ID to export (32 hex chars = 16 bytes).
#[arg(long)]
conv_id: String,
/// Output path for the .qpqt transcript file.
#[arg(long, default_value = "transcript.qpqt")]
output: PathBuf,
/// Password used to encrypt the transcript (separate from the state/DB password).
#[arg(long, env = "QPQ_TRANSCRIPT_PASSWORD")]
transcript_password: Option<String>,
/// Password for the encrypted conversation database (if any).
#[arg(long, env = "QPQ_STATE_PASSWORD")]
db_password: Option<String>,
},
/// Verify the hash-chain integrity of a transcript file without decrypting content.
ExportVerify {
/// Path to the .qpqt transcript file to verify.
#[arg(long)]
input: PathBuf,
},
}
// ── Helpers ───────────────────────────────────────────────────────────────────
@@ -361,9 +417,12 @@ async fn main() -> anyhow::Result<()> {
let args = Args::parse();
// For the REPL, defer init_auth so it can resolve its own token via OPAQUE.
// For the REPL and TUI, defer init_auth so they can resolve their own token via OPAQUE.
// For all other subcommands, initialize auth immediately.
#[cfg(not(feature = "tui"))]
let is_repl = matches!(args.command, None | Some(Command::Repl { .. }));
#[cfg(feature = "tui")]
let is_repl = matches!(args.command, None | Some(Command::Repl { .. }) | Some(Command::Tui { .. }));
if !is_repl {
let auth_ctx = ClientAuth::from_parts(args.access_token.clone(), args.device_id.clone());
init_auth(auth_ctx);
@@ -615,5 +674,53 @@ async fn main() -> anyhow::Result<()> {
))
.await
}
#[cfg(feature = "tui")]
Command::Tui {
state,
server,
username,
password,
} => {
let state = derive_state_path(state, username.as_deref());
let local = tokio::task::LocalSet::new();
local
.run_until(run_tui(
&state,
&server,
&args.ca_cert,
&args.server_name,
state_pw,
username.as_deref(),
password.as_deref(),
&args.access_token,
args.device_id.as_deref(),
))
.await
}
Command::Export {
conv_db,
conv_id,
output,
transcript_password,
db_password,
} => {
// Prompt for transcript password if not provided.
let tp = match transcript_password {
Some(p) => p,
None => {
eprint!("Transcript password: ");
rpassword::read_password()
.context("failed to read transcript password")?
}
};
cmd_export(
&conv_db,
&conv_id,
&output,
&tp,
db_password.as_deref().or(state_pw),
)
}
Command::ExportVerify { input } => cmd_export_verify(&input),
}
}