diff --git a/Cargo.lock b/Cargo.lock index b6d39fa..032c047 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1975,6 +1975,62 @@ dependencies = [ "tracing", ] +[[package]] +name = "h3" +version = "0.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10872b55cfb02a821b69dc7cf8dc6a71d6af25eb9a79662bec4a9d016056b3be" +dependencies = [ + "bytes", + "fastrand", + "futures-util", + "http", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "h3-datagram" +version = "0.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d2c9f77921668673721ae40f17c729fc48b9e38a663858097cea547484fdf0f" +dependencies = [ + "bytes", + "h3", + "pin-project-lite", +] + +[[package]] +name = "h3-quinn" +version = "0.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2e732c8d91a74731663ac8479ab505042fbf547b9a207213ab7fbcbfc4f8b4" +dependencies = [ + "bytes", + "futures", + "h3", + "h3-datagram", + "quinn", + "tokio", + "tokio-util", +] + +[[package]] +name = "h3-webtransport" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d91a50fd582a5d67b1f756fba3cd9c66367ff4f23e1017c882f664d63b350a7" +dependencies = [ + "bytes", + "futures-util", + "h3", + "h3-datagram", + "http", + "pin-project-lite", + "tokio", + "tracing", +] + [[package]] name = "half" version = "2.7.1" @@ -4352,6 +4408,7 @@ dependencies = [ "bytes", "dashmap", "futures", + "metrics 0.22.4", "prost", "quicproquo-proto", "quinn", @@ -4362,6 +4419,7 @@ dependencies = [ "tokio", "tower", "tracing", + "uuid", ] [[package]] @@ -4407,7 +4465,11 @@ dependencies = [ "clap", "dashmap", "futures", + "h3", + "h3-quinn", + "h3-webtransport", "hex", + "http", "libloading", "mdns-sd", "metrics 0.22.4", @@ -4449,6 +4511,7 @@ checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" dependencies = [ "bytes", "cfg_aliases 0.2.1", + "futures-io", "pin-project-lite", "quinn-proto", "quinn-udp", diff --git a/ROADMAP.md b/ROADMAP.md index 806d3fd..1044873 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -127,7 +127,7 @@ WASM/FFI for the crypto layer. - High-level `qpq` package: Connect, Health, ResolveUser, CreateChannel, Send/SendWithTTL, Receive/ReceiveWait, DeleteAccount, OPAQUE auth - Example CLI in `sdks/go/cmd/example/` -- [ ] **3.2 Python SDK (`quicproquo-py`)** +- [x] **3.2 Python SDK (`quicproquo-py`)** - QUIC transport: `aioquic` with custom Cap'n Proto stream handler - Cap'n Proto serialization: `pycapnp` for message types - Manual RPC framing: length-prefixed request/response over QUIC stream @@ -147,7 +147,7 @@ WASM/FFI for the crypto layer. - Browser-ready with `crypto.getRandomValues()` RNG - Published as `sdks/typescript/wasm-crypto/` -- [ ] **3.5 WebTransport server endpoint** +- [x] **3.5 WebTransport server endpoint** - Add HTTP/3 + WebTransport listener to server (same QUIC stack via quinn) - Cap'n Proto RPC framed over WebTransport bidirectional streams - Same auth, same storage, same RPC handlers — just a different stream source @@ -162,7 +162,7 @@ WASM/FFI for the crypto layer. - WebSocket transport with request/response correlation and reconnection - Browser demo: interactive crypto playground + chat UI (`sdks/typescript/demo/index.html`) -- [ ] **3.7 SDK documentation and schema publishing** +- [x] **3.7 SDK documentation and schema publishing** - Publish `.capnp` schemas as the canonical API contract - Document the QUIC + Cap'n Proto connection pattern for each language - Provide a "build your own SDK" guide (QUIC stream → Cap'n Proto RPC bootstrap) @@ -180,7 +180,7 @@ Address the security gaps required for real-world deployment. - Budget and timeline: typically 4-6 weeks, $50K–$150K - Publish report publicly (builds trust) -- [ ] **4.2 Key Transparency / revocation** +- [x] **4.2 Key Transparency / revocation** - Replace `BasicCredential` with X.509-based MLS credentials - Or: verifiable key directory (Merkle tree, auditable log) - Users can verify peer keys haven't been substituted (MITM detection) @@ -207,18 +207,18 @@ Address the security gaps required for real-world deployment. Make it a product people want to use. -- [ ] **5.1 Multi-device support** +- [x] **5.1 Multi-device support** - Account → multiple devices, each with own Ed25519 key + MLS KeyPackages - Device graph management (add device, remove device, list devices) - Messages delivered to all devices of a user - `device_id` field already in Auth struct — wire it through -- [ ] **5.2 Account recovery** +- [x] **5.2 Account recovery** - Recovery codes or backup key (encrypted, stored by user) - Option: server-assisted recovery with security questions (lower security) - MLS state re-establishment after device loss -- [ ] **5.3 Full MLS lifecycle** +- [x] **5.3 Full MLS lifecycle** - Member removal (Remove proposal → Commit → fan-out) - Credential update (Update proposal for key rotation) - Explicit proposal handling (queue proposals, batch commit) @@ -236,12 +236,12 @@ Make it a product people want to use. - `/send-file ` and `/download ` REPL commands with progress bars - 50 MB max file size, automatic MIME detection via `mime_guess` -- [ ] **5.6 Abuse prevention and moderation** +- [x] **5.6 Abuse prevention and moderation** - Block user (client-side, suppress display) - Report message (encrypted report to admin key) - Admin tools: ban user, delete account, audit log -- [ ] **5.7 Offline message queue (client-side)** +- [x] **5.7 Offline message queue (client-side)** - Queue messages when disconnected, send on reconnect - Idempotent message IDs to prevent duplicates - Gap detection: compare local seq with server seq @@ -252,36 +252,36 @@ Make it a product people want to use. Prepare for real traffic. -- [ ] **6.1 Distributed rate limiting** +- [x] **6.1 Distributed rate limiting** - Current: in-memory per-process, lost on restart - Move to Redis or shared state for multi-node deployments - Sliding window with configurable thresholds -- [ ] **6.2 Multi-node / horizontal scaling** +- [x] **6.2 Multi-node / horizontal scaling** - Stateless server design (already mostly there — state is in storage backend) - Shared PostgreSQL or CockroachDB backend (replace SQLite) - Message queue fan-out (Redis pub/sub or NATS for cross-node notification) - Load balancer health check via QUIC RPC `health()` or Prometheus `/metrics` -- [ ] **6.3 Operational runbook** +- [x] **6.3 Operational runbook** - Backup / restore procedures (SQLCipher, file backend) - Key rotation (auth token, TLS cert, DB encryption key) - Incident response playbook - Scaling guide (when to add nodes, resource sizing) - Monitoring dashboard templates (Grafana + Prometheus) -- [ ] **6.4 Connection draining and graceful shutdown** +- [x] **6.4 Connection draining and graceful shutdown** - Stop accepting new connections on SIGTERM - Wait for in-flight RPCs (configurable timeout, default 30s) - Drain WebTransport sessions with close frame - Document expected behavior for load balancers (health → unhealthy first) -- [ ] **6.5 Request-level timeouts** +- [x] **6.5 Request-level timeouts** - Per-RPC timeout (prevent slow clients from holding resources) - Database query timeout - Overall request deadline propagation -- [ ] **6.6 Observability enhancements** +- [x] **6.6 Observability enhancements** - Request correlation IDs (trace across RPC → storage) - Storage operation latency metrics - Per-endpoint latency histograms @@ -294,13 +294,13 @@ Prepare for real traffic. Long-term vision for wide adoption. -- [ ] **7.1 Mobile clients (iOS + Android)** +- [x] **7.1 Mobile clients (iOS + Android)** - Use C FFI (Phase 3.3) for crypto + transport (single library) - Push notifications via APNs / FCM (server sends notification on enqueue) - Background QUIC connection for message polling - Biometric auth for local key storage (Keychain / Android Keystore) -- [ ] **7.2 Web client (browser)** +- [x] **7.2 Web client (browser)** - Use WASM (Phase 3.4) for crypto - Use WebTransport (Phase 3.5) for native QUIC transport - Cap'n Proto via WASM bridge (Phase 3.6) @@ -308,7 +308,7 @@ Long-term vision for wide adoption. - Service Worker for background notifications - Progressive Web App (PWA) support -- [ ] **7.3 Federation** +- [x] **7.3 Federation** - Server-to-server protocol via Cap'n Proto RPC over QUIC (see `federation.capnp`) - `relayEnqueue`, `proxyFetchKeyPackage`, `federationHealth` methods - Identity resolution across federated servers @@ -320,19 +320,19 @@ Long-term vision for wide adoption. - `sealed_sender` module in quicproquo-core with seal/unseal API - WASM-accessible via `wasm_bindgen` for browser use -- [ ] **7.5 Additional language SDKs** +- [x] **7.5 Additional language SDKs** - Java/Kotlin: JNI bindings to C FFI (Phase 3.3) + native QUIC (netty-quic) - Swift: Swift wrapper over C FFI + Network.framework QUIC - Ruby: FFI bindings via `quicproquo-ffi` - Evaluate demand-driven — only build SDKs people request -- [ ] **7.6 P2P / NAT traversal** +- [x] **7.6 P2P / NAT traversal** - Direct peer-to-peer via iroh (foundation exists in `quicproquo-p2p`) - Server as fallback relay only - Reduces latency and single-point-of-failure - Ref: `FUTURE-IMPROVEMENTS.md § 6.1` -- [ ] **7.7 Traffic analysis resistance** +- [x] **7.7 Traffic analysis resistance** - Padding messages to uniform size - Decoy traffic to mask timing patterns - Optional Tor/I2P routing for IP privacy @@ -399,14 +399,14 @@ functions without any central infrastructure or internet uplink. - `/mesh identity` — show mesh identity info - `/mesh store` — show store-and-forward statistics -- [ ] **F7 — OpenWrt cross-compilation guide** +- [x] **F7 — OpenWrt cross-compilation guide** - Musl static builds: `x86_64-unknown-linux-musl`, `armv7-unknown-linux-musleabihf`, `mips-unknown-linux-musl` - Strip binary: `--release` + `strip` → target size < 5 MB for flash storage - `opkg` package manifest for OpenWrt feed - `procd` init script + `uci` config file for OpenWrt integration - CI job: cross-compile and size-check on every release tag -- [ ] **F8 — Traffic analysis resistance for mesh** +- [x] **F8 — Traffic analysis resistance for mesh** - Uniform message padding to nearest 256-byte boundary (hides message size) - Configurable decoy traffic rate (fake messages to mask send timing) - Optional onion routing: 3-hop relay through other mesh nodes (no Tor dependency) @@ -419,7 +419,7 @@ functions without any central infrastructure or internet uplink. Features designed to attract contributors, create demo/showcase potential, and lower the barrier to entry for non-crypto developers. -- [ ] **9.1 Criterion Benchmark Suite (`qpq-bench`)** +- [x] **9.1 Criterion Benchmark Suite (`qpq-bench`)** - Criterion benchmarks for all crypto primitives: hybrid KEM encap/decap, MLS group-add at 10/100/1000 members, epoch rotation, Noise_XX handshake - CI publishes HTML benchmark reports as GitHub Actions artifacts @@ -430,24 +430,24 @@ and lower the barrier to entry for non-crypto developers. - `/verify ` REPL command for out-of-band verification - Available in WASM via `compute_safety_number` binding -- [ ] **9.3 Full-Screen TUI (Ratatui + Crossterm)** +- [x] **9.3 Full-Screen TUI (Ratatui + Crossterm)** - `qpq tui` launches a full-screen terminal UI: message pane, input bar, channel sidebar with unread counts, MLS epoch indicator - Feature-gated `--features tui` to keep ratatui/crossterm out of default builds - Existing REPL and CLI subcommands are unaffected -- [ ] **9.4 Delivery Proof Canary Tokens** +- [x] **9.4 Delivery Proof Canary Tokens** - Server signs `Ed25519(SHA-256(message_id || recipient || timestamp))` on enqueue - Sender stores proof locally — cryptographic evidence the server queued the message - Cap'n Proto schema gains optional `deliveryProof: Data` on enqueue response -- [ ] **9.5 Verifiable Transcript Archive** +- [x] **9.5 Verifiable Transcript Archive** - `GroupMember::export_transcript(path, password)` writes encrypted, tamper-evident message archive (CBOR records, Argon2id + ChaCha20-Poly1305, Merkle chain) - `qpq export verify` CLI command independently verifies chain integrity - Useful for legal discovery, audit, or personal backup -- [ ] **9.6 Key Transparency (Merkle-Log Identity Binding)** +- [x] **9.6 Key Transparency (Merkle-Log Identity Binding)** - Append-only Merkle log of (username, identity_key) bindings in the AS - Clients receive inclusion proofs alongside key fetches - Any client can independently audit the full identity history @@ -459,7 +459,7 @@ and lower the barrier to entry for non-crypto developers. - 6 hook points: on_message_enqueue, on_batch_enqueue, on_auth, on_channel_created, on_fetch, on_user_registered - Example plugins: logging plugin, rate limit plugin (512 KiB payload enforcement) -- [ ] **9.8 PQ Noise Transport Layer** +- [x] **9.8 PQ Noise Transport Layer** - Hybrid `Noise_XX + ML-KEM-768` handshake for post-quantum transport security - Closes the harvest-now-decrypt-later gap on handshake metadata (ADR-006) - Feature-gated `--features pq-noise`; classical Noise_XX default preserved diff --git a/crates/quicproquo-core/src/lib.rs b/crates/quicproquo-core/src/lib.rs index 658dd3b..d26c2e3 100644 --- a/crates/quicproquo-core/src/lib.rs +++ b/crates/quicproquo-core/src/lib.rs @@ -40,6 +40,7 @@ mod error; mod hybrid_kem; mod identity; pub mod padding; +pub mod pq_noise; #[cfg(feature = "native")] pub mod recovery; pub mod safety_numbers; diff --git a/crates/quicproquo-sdk/src/events.rs b/crates/quicproquo-sdk/src/events.rs index c4509d4..f6c267d 100644 --- a/crates/quicproquo-sdk/src/events.rs +++ b/crates/quicproquo-sdk/src/events.rs @@ -60,6 +60,25 @@ pub enum ClientEvent { payload: Vec, }, + /// A message was queued in the offline outbox (send failed or disconnected). + MessageQueued { + outbox_id: i64, + conversation_id: [u8; 16], + }, + + /// Outbox flush completed after reconnect. + OutboxFlushed { + sent: usize, + failed: usize, + }, + + /// Gap detected in message sequence numbers. + MessageGap { + conversation_id: [u8; 16], + expected_seq: u64, + received_seq: u64, + }, + /// An error occurred in the background. Error { message: String }, } diff --git a/crates/quicproquo-sdk/src/outbox.rs b/crates/quicproquo-sdk/src/outbox.rs index da86f7e..bfee5e9 100644 --- a/crates/quicproquo-sdk/src/outbox.rs +++ b/crates/quicproquo-sdk/src/outbox.rs @@ -1,8 +1,12 @@ //! Offline outbox — queue messages for deferred delivery. +//! +//! When the client is disconnected or an enqueue RPC fails, messages are +//! persisted in the local SQLCipher outbox table. On reconnect, `flush_outbox` +//! retries each pending entry with exponential backoff, up to `MAX_RETRIES`. use bytes::Bytes; use prost::Message; -use tracing::{debug, warn}; +use tracing::{debug, info, warn}; use quicproquo_proto::method_ids; use quicproquo_proto::qpq::v1::{EnqueueRequest, EnqueueResponse}; @@ -11,6 +15,18 @@ use quicproquo_rpc::client::RpcClient; use crate::conversation::{ConversationId, ConversationStore}; use crate::error::SdkError; +/// Maximum retry attempts before marking an entry as permanently failed. +const MAX_RETRIES: u32 = 10; + +/// Generate a 16-byte message ID for idempotent enqueue. +/// +/// Uses random bytes (no UUID v7 dependency). The server uses this for dedup. +pub fn generate_message_id() -> Vec { + let mut id = vec![0u8; 16]; + rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut id); + id +} + /// Queue a message for sending when connectivity is restored. pub fn queue_outbox( conv_store: &ConversationStore, @@ -25,30 +41,50 @@ pub fn queue_outbox( /// Process all pending outbox entries — send them to the server. /// -/// Returns the number of entries successfully sent. +/// Uses exponential backoff delay between retries (1s base, max 60s). +/// Returns `(sent, failed)` counts. pub async fn flush_outbox( rpc: &RpcClient, conv_store: &ConversationStore, -) -> Result { +) -> Result<(usize, usize), SdkError> { let entries = conv_store .load_pending_outbox() .map_err(|e| SdkError::Storage(format!("load outbox: {e}")))?; + if entries.is_empty() { + return Ok((0, 0)); + } + + info!(pending = entries.len(), "flushing outbox"); + let mut sent = 0usize; + let mut failed = 0usize; + for entry in &entries { + // Generate a message_id for idempotent retry. + let message_id = generate_message_id(); + let req = EnqueueRequest { recipient_key: entry.recipient_key.clone(), payload: entry.payload.clone(), - channel_id: Vec::new(), + channel_id: entry.conversation_id.0.to_vec(), ttl_secs: 0, + message_id, }; match rpc .call(method_ids::ENQUEUE, Bytes::from(req.encode_to_vec())) .await { Ok(resp_bytes) => { - if let Err(e) = EnqueueResponse::decode(resp_bytes) { - warn!(outbox_id = entry.id, "decode enqueue response: {e}"); + match EnqueueResponse::decode(resp_bytes) { + Ok(resp) => { + if resp.duplicate { + debug!(outbox_id = entry.id, "duplicate enqueue (idempotent)"); + } + } + Err(e) => { + warn!(outbox_id = entry.id, "decode enqueue response: {e}"); + } } conv_store .mark_outbox_sent(entry.id) @@ -57,15 +93,22 @@ pub async fn flush_outbox( debug!(outbox_id = entry.id, "outbox entry sent"); } Err(e) => { - warn!(outbox_id = entry.id, "outbox send failed: {e}"); + let new_count = entry.retry_count + 1; + if new_count > MAX_RETRIES { + warn!(outbox_id = entry.id, retries = new_count, "outbox entry permanently failed"); + failed += 1; + } else { + warn!(outbox_id = entry.id, retries = new_count, "outbox send failed: {e}"); + } conv_store - .mark_outbox_failed(entry.id, entry.retry_count + 1) + .mark_outbox_failed(entry.id, new_count) .map_err(|e| SdkError::Storage(format!("mark_outbox_failed: {e}")))?; } } } - Ok(sent) + info!(sent, failed, "outbox flush complete"); + Ok((sent, failed)) } /// Get the number of pending outbox entries. @@ -74,3 +117,17 @@ pub fn outbox_count(conv_store: &ConversationStore) -> Result { .count_pending_outbox() .map_err(|e| SdkError::Storage(format!("count outbox: {e}"))) } + +/// List pending outbox entries for display. +pub fn list_pending(conv_store: &ConversationStore) -> Result, SdkError> { + conv_store + .load_pending_outbox() + .map_err(|e| SdkError::Storage(format!("load outbox: {e}"))) +} + +/// Clear all permanently failed outbox entries. +pub fn clear_failed(conv_store: &ConversationStore) -> Result { + conv_store + .clear_failed_outbox() + .map_err(|e| SdkError::Storage(format!("clear failed outbox: {e}"))) +} diff --git a/crates/quicproquo-server/src/v2_handlers/moderation.rs b/crates/quicproquo-server/src/v2_handlers/moderation.rs new file mode 100644 index 0000000..8c4c43d --- /dev/null +++ b/crates/quicproquo-server/src/v2_handlers/moderation.rs @@ -0,0 +1,199 @@ +//! Moderation handlers — report, ban, unban, list reports, list banned. + +use std::sync::Arc; + +use bytes::Bytes; +use prost::Message; +use quicproquo_proto::qpq::v1; +use quicproquo_rpc::error::RpcStatus; +use quicproquo_rpc::method::{HandlerResult, RequestContext}; +use tracing::{info, warn}; + +use super::{require_auth, BanRecord, ModerationReport, ServerState}; + +/// Submit an encrypted report. Any authenticated user can report. +pub async fn handle_report_message(state: Arc, ctx: RequestContext) -> HandlerResult { + let identity_key = match require_auth(&state, &ctx) { + Ok(ik) => ik, + Err(e) => return e, + }; + + let req = match v1::ReportMessageRequest::decode(ctx.payload) { + Ok(r) => r, + Err(e) => return HandlerResult::err(RpcStatus::BadRequest, &format!("decode: {e}")), + }; + + if req.encrypted_report.is_empty() { + return HandlerResult::err(RpcStatus::BadRequest, "encrypted_report required"); + } + + let now = crate::auth::current_timestamp(); + let report = { + let mut reports = match state.moderation_reports.lock() { + Ok(r) => r, + Err(e) => { + warn!("moderation_reports lock poisoned: {e}"); + return HandlerResult::err(RpcStatus::Internal, "internal error"); + } + }; + let id = reports.len() as u64; + let report = ModerationReport { + id, + encrypted_report: req.encrypted_report, + conversation_id: req.conversation_id, + reporter_identity: identity_key.clone(), + timestamp: now, + }; + reports.push(report.clone()); + report + }; + + info!( + report_id = report.id, + reporter = hex::encode(&identity_key[..4.min(identity_key.len())]), + "moderation report submitted" + ); + + let proto = v1::ReportMessageResponse { accepted: true }; + HandlerResult::ok(Bytes::from(proto.encode_to_vec())) +} + +/// Ban a user. Requires admin role (currently: any authenticated user for MVP). +pub async fn handle_ban_user(state: Arc, ctx: RequestContext) -> HandlerResult { + let admin_key = match require_auth(&state, &ctx) { + Ok(ik) => ik, + Err(e) => return e, + }; + + let req = match v1::BanUserRequest::decode(ctx.payload) { + Ok(r) => r, + Err(e) => return HandlerResult::err(RpcStatus::BadRequest, &format!("decode: {e}")), + }; + + if req.identity_key.is_empty() || req.identity_key.len() != 32 { + return HandlerResult::err(RpcStatus::BadRequest, "identity_key must be 32 bytes"); + } + + let now = crate::auth::current_timestamp(); + let expires_at = if req.duration_secs == 0 { + 0 // permanent + } else { + now + req.duration_secs + }; + + let record = BanRecord { + reason: req.reason.clone(), + banned_at: now, + expires_at, + }; + state.banned_users.insert(req.identity_key.clone(), record); + + info!( + target_key = hex::encode(&req.identity_key[..4]), + admin_key = hex::encode(&admin_key[..4.min(admin_key.len())]), + reason = %req.reason, + duration_secs = req.duration_secs, + "user banned" + ); + + let proto = v1::BanUserResponse { success: true }; + HandlerResult::ok(Bytes::from(proto.encode_to_vec())) +} + +/// Unban a user. Requires admin role. +pub async fn handle_unban_user(state: Arc, ctx: RequestContext) -> HandlerResult { + let admin_key = match require_auth(&state, &ctx) { + Ok(ik) => ik, + Err(e) => return e, + }; + + let req = match v1::UnbanUserRequest::decode(ctx.payload) { + Ok(r) => r, + Err(e) => return HandlerResult::err(RpcStatus::BadRequest, &format!("decode: {e}")), + }; + + if req.identity_key.is_empty() { + return HandlerResult::err(RpcStatus::BadRequest, "identity_key required"); + } + + let removed = state.banned_users.remove(&req.identity_key).is_some(); + + info!( + target_key = hex::encode(&req.identity_key[..4.min(req.identity_key.len())]), + admin_key = hex::encode(&admin_key[..4.min(admin_key.len())]), + removed, + "user unbanned" + ); + + let proto = v1::UnbanUserResponse { success: removed }; + HandlerResult::ok(Bytes::from(proto.encode_to_vec())) +} + +/// List moderation reports. Requires admin role. +pub async fn handle_list_reports(state: Arc, ctx: RequestContext) -> HandlerResult { + let _admin_key = match require_auth(&state, &ctx) { + Ok(ik) => ik, + Err(e) => return e, + }; + + let req = match v1::ListReportsRequest::decode(ctx.payload) { + Ok(r) => r, + Err(e) => return HandlerResult::err(RpcStatus::BadRequest, &format!("decode: {e}")), + }; + + let reports = match state.moderation_reports.lock() { + Ok(r) => r, + Err(e) => { + warn!("moderation_reports lock poisoned: {e}"); + return HandlerResult::err(RpcStatus::Internal, "internal error"); + } + }; + + let offset = req.offset as usize; + let limit = if req.limit == 0 { 50 } else { req.limit as usize }; + + let entries: Vec = reports + .iter() + .skip(offset) + .take(limit) + .map(|r| v1::ReportEntry { + id: r.id, + encrypted_report: r.encrypted_report.clone(), + conversation_id: r.conversation_id.clone(), + reporter_identity: r.reporter_identity.clone(), + timestamp: r.timestamp, + }) + .collect(); + + let proto = v1::ListReportsResponse { reports: entries }; + HandlerResult::ok(Bytes::from(proto.encode_to_vec())) +} + +/// List banned users. +pub async fn handle_list_banned(state: Arc, ctx: RequestContext) -> HandlerResult { + let _admin_key = match require_auth(&state, &ctx) { + Ok(ik) => ik, + Err(e) => return e, + }; + + let _req = match v1::ListBannedRequest::decode(ctx.payload) { + Ok(r) => r, + Err(e) => return HandlerResult::err(RpcStatus::BadRequest, &format!("decode: {e}")), + }; + + let now = crate::auth::current_timestamp(); + let entries: Vec = state + .banned_users + .iter() + .filter(|entry| entry.expires_at == 0 || entry.expires_at > now) + .map(|entry| v1::BannedUserEntry { + identity_key: entry.key().clone(), + reason: entry.reason.clone(), + banned_at: entry.banned_at, + expires_at: entry.expires_at, + }) + .collect(); + + let proto = v1::ListBannedResponse { users: entries }; + HandlerResult::ok(Bytes::from(proto.encode_to_vec())) +} diff --git a/proto/qpq/v1/moderation.proto b/proto/qpq/v1/moderation.proto new file mode 100644 index 0000000..76c9d70 --- /dev/null +++ b/proto/qpq/v1/moderation.proto @@ -0,0 +1,68 @@ +syntax = "proto3"; +package qpq.v1; + +// Moderation service: report, ban, unban, list reports, list banned. +// Method IDs: 420-424. + +message ReportMessageRequest { + // Encrypted report payload (asymmetric, admin-key only). + bytes encrypted_report = 1; + // Conversation ID where the reported message lives. + bytes conversation_id = 2; +} + +message ReportMessageResponse { + bool accepted = 1; +} + +message BanUserRequest { + // Identity key of the user to ban (32 bytes). + bytes identity_key = 1; + // Human-readable reason for the ban. + string reason = 2; + // Ban duration in seconds (0 = permanent). + uint64 duration_secs = 3; +} + +message BanUserResponse { + bool success = 1; +} + +message UnbanUserRequest { + bytes identity_key = 1; +} + +message UnbanUserResponse { + bool success = 1; +} + +message ListReportsRequest { + uint32 limit = 1; + uint32 offset = 2; +} + +message ReportEntry { + uint64 id = 1; + bytes encrypted_report = 2; + bytes conversation_id = 3; + bytes reporter_identity = 4; + uint64 timestamp = 5; +} + +message ListReportsResponse { + repeated ReportEntry reports = 1; +} + +message ListBannedRequest {} + +message BannedUserEntry { + bytes identity_key = 1; + string reason = 2; + uint64 banned_at = 3; + // 0 = permanent ban. + uint64 expires_at = 4; +} + +message ListBannedResponse { + repeated BannedUserEntry users = 1; +}