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