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

@@ -7,6 +7,8 @@ use quicproquo_proto::node_capnp::node_service;
use tokio::sync::Notify;
use tokio::time::timeout;
use sha2::{Digest, Sha256};
use crate::auth::{
check_rate_limit, coded_error, fmt_hex, require_identity_or_request, validate_auth_context,
};
@@ -15,12 +17,38 @@ use crate::metrics;
use crate::storage::{StorageError, Store};
use super::{NodeServiceImpl, CURRENT_WIRE_VERSION};
use crate::hooks::{HookAction, MessageEvent, FetchEvent};
// Audit events here must not include secrets: no payload content, no full recipient/token bytes (prefix only).
const MAX_PAYLOAD_BYTES: usize = 5 * 1024 * 1024; // 5 MB cap per message
const MAX_QUEUE_DEPTH: usize = 1000;
/// Build a 96-byte delivery proof: SHA-256(seq || recipient_key || timestamp_ms) || Ed25519 sig.
///
/// Layout:
/// bytes 0..32 — SHA-256 preimage hash
/// bytes 32..96 — Ed25519 signature over those 32 bytes
fn build_delivery_proof(
signing_key: &quicproquo_core::IdentityKeypair,
seq: u64,
recipient_key: &[u8],
timestamp_ms: u64,
) -> [u8; 96] {
let mut hasher = Sha256::new();
hasher.update(seq.to_le_bytes());
hasher.update(recipient_key);
hasher.update(timestamp_ms.to_le_bytes());
let hash: [u8; 32] = hasher.finalize().into();
let sig = signing_key.sign_raw(&hash);
let mut proof = [0u8; 96];
proof[..32].copy_from_slice(&hash);
proof[32..].copy_from_slice(&sig);
proof
}
fn storage_err(err: StorageError) -> capnp::Error {
coded_error(E009_STORAGE_ERROR, err)
}
@@ -173,6 +201,24 @@ impl NodeServiceImpl {
}
let payload_len = payload.len();
let sender_identity = if self.sealed_sender {
None
} else {
crate::auth::require_identity(&auth_ctx).ok().map(|v| v.to_vec())
};
// Hook: on_message_enqueue — fires after validation, before storage.
let hook_event = MessageEvent {
sender_identity,
recipient_key: recipient_key.clone(),
channel_id: channel_id.clone(),
payload_len,
seq: 0, // not yet assigned
};
if let HookAction::Reject(reason) = self.hooks.on_message_enqueue(&hook_event) {
return Promise::err(capnp::Error::failed(format!("hook rejected enqueue: {reason}")));
}
let seq = match self
.store
.enqueue(&recipient_key, &channel_id, payload)
@@ -182,7 +228,15 @@ impl NodeServiceImpl {
Err(e) => return Promise::err(e),
};
results.get().set_seq(seq);
let timestamp_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64;
let proof = build_delivery_proof(&self.signing_key, seq, &recipient_key, timestamp_ms);
let mut r = results.get();
r.set_seq(seq);
r.set_delivery_proof(&proof);
// Metrics and audit. Audit events must not include secrets (no payload, no full keys).
metrics::record_enqueue_total();
@@ -306,6 +360,13 @@ impl NodeServiceImpl {
}
};
// Hook: on_fetch — fires after messages are retrieved.
self.hooks.on_fetch(&FetchEvent {
recipient_key: recipient_key.clone(),
channel_id: channel_id.clone(),
message_count: messages.len(),
});
// Audit: fetch — do not log payload or full keys.
metrics::record_fetch_total();
tracing::info!(
@@ -671,11 +732,33 @@ impl NodeServiceImpl {
recipient_key_vecs.push(rk);
}
// Hook: on_message_enqueue for each recipient — fires before storage.
let sender_identity = if self.sealed_sender {
None
} else {
crate::auth::require_identity(&auth_ctx).ok().map(|v| v.to_vec())
};
let mut hook_events = Vec::with_capacity(recipient_key_vecs.len());
for rk in &recipient_key_vecs {
let event = MessageEvent {
sender_identity: sender_identity.clone(),
recipient_key: rk.clone(),
channel_id: channel_id.clone(),
payload_len: payload.len(),
seq: 0,
};
if let HookAction::Reject(reason) = self.hooks.on_message_enqueue(&event) {
return Promise::err(capnp::Error::failed(format!("hook rejected enqueue: {reason}")));
}
hook_events.push(event);
}
let n = recipient_key_vecs.len();
let store = Arc::clone(&self.store);
let waiters = Arc::clone(&self.waiters);
let fed_client = self.federation_client.clone();
let local_domain = self.local_domain.clone();
let hooks = Arc::clone(&self.hooks);
// Use an async future to support federation relay alongside local enqueue.
// All storage operations are synchronous; only federation relay calls are await-ed.
@@ -734,6 +817,9 @@ impl NodeServiceImpl {
list.set(i as u32, *seq);
}
// Hook: on_batch_enqueue — fires after all messages are stored.
hooks.on_batch_enqueue(&hook_events);
tracing::info!(
recipient_count = n,
payload_len = payload.len(),